Compare commits

..
Author SHA1 Message Date
swcho 738b1f7735 Merge branch 'main' into iipbs/sn/ii 2025-09-30 15:44:57 +09:00
1662 changed files with 19169 additions and 182578 deletions
+4 -13
View File
@@ -4,7 +4,10 @@
# dependencies
node_modules/
.idea/
# build
.next/
dist/
# build
.next
@@ -18,15 +21,3 @@ apps/fo/.next/**/*
# env
#.env*
.qodo
# next-agents-md
.next-docs/
pnpm-lock.yaml
settings.json
.npmrc
.cursor
.agent
apps/**/tsconfig.tsbuildinfo
logs/
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" != "main" ]; then
exit 0
fi
echo "Branch: $BRANCH"
echo "Changed packages (dry-run):"
pnpm turbo run build --filter="...[origin/$BRANCH]" --dry-run
echo "Starting build..."
pnpm turbo run build --filter="...[origin/$BRANCH]"
-8
View File
@@ -1,8 +0,0 @@
# apps/as/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=
NEXT_PUBLIC_PROXY_HOST=
NEXT_PUBLIC_SITE_ID=
NEXT_PUBLIC_SERVER_API_URL=
# 401 시 브라우저 리다이렉트 대상
NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL=/auth/login
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}
-36
View File
@@ -1,36 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
-8
View File
@@ -1,8 +0,0 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2,
"plugins": ["prettier-plugin-tailwindcss"]
}
-248
View File
@@ -1,248 +0,0 @@
# AS Application
Next.js 애플리케이션 with FSD(Feature Sliced Design) 아키텍처
## 기술 스택
- **프레임워크**: Next.js 15 + TypeScript
- **스타일링**: Tailwind CSS
- **상태관리**: Zustand
- **데이터 페칭**: TanStack Query (React Query)
- **폼 관리**: React Hook Form + Zod
- **테이블**: TanStack Table
- **HTTP 클라이언트**: Axios
- **날짜 처리**: Day.js
## 프로젝트 구조 (FSD)
이 프로젝트는 **Feature-Sliced Design v2.1**을 따르며, 계층적 아키텍처로 구성되어 있습니다.
```
as/
├── app/ # App 레이어 (Next.js App Router)
│ ├── (routes)/ # 라우트 구조
│ ├── layout.tsx # 루트 레이아웃
│ ├── page.tsx # 홈 페이지
│ ├── globals.css # 글로벌 스타일
│ └── providers.tsx # 전역 providers
│
├── widgets/ # Widgets 레이어
│ └── [widget-name]/ # 큰 독립적 UI 블록 (여러 페이지에서 재사용)
│ ├── ui/
│ ├── model/
│ └── index.ts
│
├── features/ # Features 레이어
│ └── [feature-name]/ # 사용자 상호작용 기능 (여러 페이지에서 재사용)
│ ├── ui/
│ ├── api/
│ ├── model/
│ └── index.ts
│
├── entities/ # Entities 레이어
│ └── [entity-name]/ # 비즈니스 엔티티 (User, Product 등)
│ ├── model/
│ ├── api/
│ ├── ui/
│ └── index.ts
│
└── shared/ # Shared 레이어
├── ui/ # UI Kit (비즈니스 로직 없는 공통 컴포넌트)
├── api/ # API 클라이언트 설정
├── lib/ # 내부 라이브러리 (dates, colors 등)
├── config/ # 환경 설정, feature flags
├── types/ # 공통 타입 정의
└── routes/ # 라우트 상수
```
### 계층 구조 이해하기
FSD는 **3단계 계층**으로 구성됩니다:
1. **Layers (레이어)**: 책임과 의존성에 따른 수평적 분리 (app, pages, widgets, features, entities, shared)
2. **Slices (슬라이스)**: 비즈니스 도메인에 따른 수직적 분리 (user, product, auth-login 등)
3. **Segments (세그먼트)**: 기술적 목적에 따른 분리 (ui, api, model, lib 등)
> 📚 각 레이어의 자세한 설명은 해당 폴더의 README.md를 참고하세요.
## FSD 레이어 설명
FSD는 계층적 아키텍처로, 각 레이어는 책임의 범위와 의존성의 정도에 따라 구분됩니다. 7개의 표준화된 레이어가 있으며, 프로젝트에 필요한 레이어만 사용하면 됩니다.
### 레이어 계층 구조 (위에서 아래로)
```
app/ ↑ 가장 높은 책임, 가장 많은 의존성
pages/ |
widgets/ |
features/ |
entities/ |
shared/ ↓ 가장 낮은 책임, 의존성 없음
```
### 1. App
- **목적**: 앱 전체에 영향을 주는 기술적/비즈니스적 사항
- **내용**: Context providers, 글로벌 스타일, 스토어 설정, 라우터 설정, 애널리틱스
- **특징**: 슬라이스가 없고 세그먼트로만 구성
- **의존성**: 모든 하위 레이어 사용 가능
### 2. Pages (app 디렉토리)
- **목적**: 웹사이트나 애플리케이션을 구성하는 페이지/화면
- **내용**: 페이지 UI, 로딩 상태, 에러 바운더리, 데이터 fetching
- **특징**: 한 페이지당 한 슬라이스 (유사한 페이지는 그룹화 가능)
- **의존성**: `widgets`, `features`, `entities`, `shared` 사용 가능
> 💡 **FSD v2.1 권장사항**: "pages-first" 접근 방식을 따라, 재사용되지 않는 큰 UI 블록과 비즈니스 로직은 페이지 슬라이스에 유지하세요.
### 3. Widgets
- **목적**: 큰 규모의 독립적인 UI 블록
- **내용**: 여러 features와 entities를 조합한 복합 UI
- **사용 시기**: 여러 페이지에서 재사용되거나, 페이지에 여러 개의 큰 독립적 블록이 있을 때
- **의존성**: `features`, `entities`, `shared` 사용 가능 (다른 `widgets` 불가)
### 4. Features
- **목적**: 사용자가 수행하는 주요 상호작용과 비즈니스 기능
- **내용**: 사용자 시나리오 (예: 로그인, 상품 검색, 댓글 작성)
- **사용 시기**: 여러 페이지에서 재사용되는 기능
- **의존성**: `entities`, `shared` 사용 가능 (다른 `features` 불가)
### 5. Entities
- **목적**: 비즈니스가 다루는 실제 개념 (User, Product, Order 등)
- **내용**: 데이터 모델, 타입, API 요청, 엔티티의 시각적 표현
- **특징**: 비즈니스 용어로 명명
- **의존성**: 다른 `entities`, `shared` 사용 가능
### 6. Shared
- **목적**: 애플리케이션의 기반 제공
- **내용**: API 클라이언트, UI Kit, 유틸리티 라이브러리, 글로벌 설정
- **특징**: 비즈니스 로직 없음, 슬라이스가 없고 세그먼트로만 구성
- **의존성**: 외부 라이브러리만 사용 가능 (다른 레이어 불가)
## 레이어 간 Import 규칙
> **핵심 규칙**: 슬라이스 내의 모듈(파일)은 **오직 하위 레이어**에 있는 다른 슬라이스만 import할 수 있습니다.
예시:
- `features/auth-login/ui/form.tsx`는 `entities/user`와 `shared/ui`를 import 가능
- 하지만 다른 `features/post-create`나 상위 레이어인 `widgets`는 import 불가
- 같은 슬라이스 내부(`features/auth-login/`)의 다른 파일들은 자유롭게 import 가능
## Public API 규칙
> **슬라이스 외부의 모듈은 슬라이스의 Public API만 참조할 수 있으며, 내부 파일 구조에 직접 접근할 수 없습니다.**
모든 슬라이스(및 슬라이스가 없는 레이어의 세그먼트)는 반드시 `index.ts`를 통해 Public API를 정의해야 합니다.
```typescript
// ✅ 올바른 import
import { LoginForm } from 'features/auth-login';
// ❌ 잘못된 import (내부 구조 직접 접근)
import { LoginForm } from 'features/auth-login/ui/login-form';
```
## 시작하기
```bash
# 의존성 설치
pnpm install
# 개발 서버 실행
pnpm run dev
# 프로덕션 빌드
pnpm run build
# 프로덕션 서버 실행
pnpm start
```
## 환경 변수
`.env.local` 파일 생성:
```env
NEXT_PUBLIC_API_URL=http://localhost:3000/api
```
## FSD 핵심 규칙
### 1. Import 규칙
> **슬라이스 내의 모듈(파일)은 오직 하위 레이어에 있는 슬라이스만 import할 수 있습니다.**
```typescript
// ✅ 올바른 import (하위 레이어)
// features/auth-login/ui/form.tsx
import { User } from 'entities/user';
import { Button } from 'shared/ui/button';
// ❌ 잘못된 import (같은 레이어)
// features/auth-login/ui/form.tsx
import { PostForm } from 'features/post-create'; // 금지!
// ❌ 잘못된 import (상위 레이어)
// features/auth-login/ui/form.tsx
import { Header } from 'widgets/header'; // 금지!
```
### 2. Public API 규칙
> **슬라이스 외부에서는 Public API(index.ts)를 통해서만 접근해야 합니다.**
```typescript
// ✅ 올바른 import (Public API 사용)
import { LoginForm } from 'features/auth-login';
// ❌ 잘못된 import (내부 구조 직접 접근)
import { LoginForm } from 'features/auth-login/ui/login-form';
```
**Public API 작성 시 주의사항**:
- 와일드카드 re-export(`export * from`) 사용 지양
- 필요한 것만 명시적으로 export
- 슬라이스의 인터페이스를 명확하게 표현
```typescript
// ❌ 나쁜 예시
export * from './ui/Comment';
export * from './model/comments';
// ✅ 좋은 예시
export { Comment } from './ui/comment';
export { CommentForm } from './ui/comment-form';
export { useComments } from './model/use-comments';
export type { Comment, CommentFormData } from './model/types';
```
### 3. 슬라이스 독립성
- **Zero Coupling**: 같은 레이어의 슬라이스들은 서로 독립적이어야 합니다
- **High Cohesion**: 슬라이스는 주요 목표와 관련된 대부분의 코드를 포함해야 합니다
- 예외: Entities 레이어에서 필요한 경우 `@x` 표기법으로 cross-import 가능
### 4. 세그먼트 명명 규칙
세그먼트 이름은 **목적**을 설명해야 하며, **본질**을 설명하면 안 됩니다:
- ❌ **나쁜 이름**: `components`, `hooks`, `types` (무엇인지만 설명)
- ✅ **좋은 이름**: `ui`, `api`, `model`, `lib` (왜 있는지 설명)
## 코드 규칙
- TypeScript 사용 (strict mode)
- 함수형 컴포넌트 사용
- Named exports 선호
- Tailwind CSS 사용
- ESLint + Prettier 적용
- Public API는 명시적으로 정의 (와일드카드 export 지양)
## 아키텍처 린팅
코드베이스의 FSD 규칙 준수를 자동으로 검증하려면 [Steiger](https://github.com/feature-sliced/steiger)를 사용하세요.
```bash
# Steiger 설치 및 실행
pnpm add -D @feature-sliced/steiger
npx steiger src
```
@@ -1,31 +0,0 @@
"use client";
import { useCallback } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { BaseLayout } from "@common/components";
import type { MenuItem } from "@/entities/menu";
import { PageLayout } from "@/shared/ui/page-layout";
export default function WithBaseLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const handleMenuClick = useCallback(
(item: MenuItem) => {
router.push(item.urlPath);
},
[router],
);
return (
<BaseLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
<PageLayout>{children}</PageLayout>
</BaseLayout>
);
}
@@ -1,9 +0,0 @@
"use client";
export default function MainPage() {
return (
<div className="flex h-full flex-col items-center justify-center py-10">
<h1 className="mb-4 text-3xl font-bold">Home</h1>
</div>
);
}
-169
View File
@@ -1,169 +0,0 @@
# App Layer
**App**은 애플리케이션 전체에 영향을 미치는 기술적, 비즈니스적 사항을 다루는 최상위 레이어입니다.
## 목적
- 애플리케이션의 진입점(entrypoint) 제공
- 전역 Context Providers 설정
- 라우터 구성
- 글로벌 스타일 정의
- 전역 상태 저장소 설정
- 애널리틱스 및 모니터링 설정
## 특징
- **슬라이스 없음**: App 레이어는 Shared처럼 슬라이스가 없고 세그먼트로만 구성됩니다
- **전체 앱 관심사**: 특정 비즈니스 도메인이 아닌 앱 전체와 관련된 코드만 포함
- **최상위 레이어**: 모든 하위 레이어의 모듈을 자유롭게 import 가능
## 구조 (Next.js App Router)
Next.js를 사용하는 경우, App 레이어는 주로 `app/` 디렉토리로 표현됩니다:
```
app/
├── (routes)/ # 라우트 그룹 및 페이지
│ ├── (auth)/
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── register/
│ │ └── page.tsx
│ └── (main)/
│ ├── dashboard/
│ │ └── page.tsx
│ └── settings/
│ └── page.tsx
├── layout.tsx # 루트 레이아웃
├── page.tsx # 홈 페이지
├── globals.css # 글로벌 스타일
├── providers.tsx # 전역 Providers 설정
└── error.tsx # 글로벌 에러 핸들러
```
### 일반적인 FSD 세그먼트 구조
FSD 표준 세그먼트를 사용하는 경우:
```
app/
├── entrypoint/ # 애플리케이션 진입점 (프레임워크별)
│ └── index.tsx
├── providers/ # Context Providers
│ ├── query-provider.tsx
│ ├── theme-provider.tsx
│ └── index.tsx
├── styles/ # 글로벌 스타일
│ ├── globals.css
│ └── theme.css
├── store/ # 글로벌 상태 저장소 설정
│ └── index.ts
└── routes/ # 라우터 설정
└── index.tsx
```
## 예시
### Next.js Providers 설정
```typescript
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from 'next-themes';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
}));
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</QueryClientProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
import './globals.css';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
);
}
```
### 글로벌 설정
```typescript
// app/config/analytics.ts
export function initAnalytics() {
if (typeof window !== 'undefined') {
// 애널리틱스 초기화
}
}
// app/config/monitoring.ts
export function initMonitoring() {
// 에러 모니터링 설정
}
```
## Next.js App Router와 FSD
Next.js의 `app/` 디렉토리는 FSD의 **App 레이어**와 **Pages 레이어**를 동시에 담당합니다:
- **루트 레벨 파일들** (`layout.tsx`, `providers.tsx`, `globals.css`): App 레이어
- **라우트 폴더들** (`dashboard/`, `login/` 등): Pages 레이어
각 페이지별 비즈니스 로직은:
- 재사용되지 않는 UI는 페이지 슬라이스(`app/dashboard/` 등) 안에 유지
- 재사용되는 기능은 `features/` 레이어로 분리
- 여러 페이지에서 재사용되는 큰 UI 블록은 `widgets/` 레이어로 분리
## 레이어 의존성 규칙
✅ **허용되는 의존성**:
- 모든 하위 레이어 (`pages`, `widgets`, `features`, `entities`, `shared`)
- 같은 `app` 레이어 내의 다른 세그먼트
- 외부 라이브러리
❌ **금지된 의존성**:
- 없음 (App은 최상위 레이어)
## 주의사항
1. **비대화 방지**: App 레이어가 너무 커지면 관리가 어려워집니다. 대부분의 로직은 하위 레이어에 배치하세요
2. **전역 관심사만**: 특정 페이지나 기능에만 관련된 코드는 해당 레이어로 이동
3. **라우터 레벨 로직**: 라우터 및 서버 레벨 로직은 이 레이어에 유지
4. **Processes 레이어 사용 지양**: Processes 레이어는 deprecated되었으므로 해당 로직은 `app`이나 `features`에 배치
## FSD v2.1 권장사항
최신 FSD 버전(v2.1)은 **"pages-first"** 접근 방식을 권장합니다:
- 더 많은 코드를 페이지 슬라이스에 유지
- 큰 UI 블록과 재사용되지 않는 비즈니스 로직은 즉시 추출하지 말고 페이지에 보관
- 실제로 재사용이 필요할 때만 `features`나 `widgets`로 분리
이 접근 방식은 과도한 추상화를 방지하고 코드를 더 쉽게 탐색할 수 있게 합니다.
-15
View File
@@ -1,15 +0,0 @@
@import "@common/assets/styles/base.css";
@import "@common/assets/styles/diveicon.css";
@import "@common/components/index.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
color: var(--foreground);
background: var(--background);
font-family: var(--font-noto-sans-kr), sans-serif;
font-size: 10px;
}
-30
View File
@@ -1,30 +0,0 @@
import type { Metadata } from "next";
import { Noto_Sans_KR } from "next/font/google";
import { Providers } from "./providers";
import "./globals.css";
const notoSansKR = Noto_Sans_KR({
subsets: ["latin"],
weight: ["100", "300", "400", "500", "700", "900"],
display: "swap",
variable: "--font-noto-sans-kr",
});
export const metadata: Metadata = {
title: "행정지원시스템",
description: "행정지원시스템",
};
interface RootLayoutProps {
children: React.ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="ko" className={notoSansKR.variable}>
<body className={`${notoSansKR.className} antialiased`}>
<Providers>{children}</Providers>
</body>
</html>
);
}
-18
View File
@@ -1,18 +0,0 @@
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient } from "@/shared/lib";
interface ProvidersProps {
children: React.ReactNode;
}
export function Providers({ children }: ProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
-114
View File
@@ -1,114 +0,0 @@
# Entities Layer
**Entities**는 프로젝트가 다루는 실제 비즈니스 개념을 나타내는 레이어입니다. 비즈니스에서 제품을 설명할 때 사용하는 용어들(예: User, Product, Order)이 주로 엔티티가 됩니다.
## 목적
- 비즈니스 엔티티의 데이터 모델 및 타입 정의
- 엔티티 관련 API 요청 함수 관리
- 엔티티의 시각적 표현 제공 (재사용 가능한 UI)
- 여러 페이지에서 동일한 외관을 재사용할 수 있도록 지원
## 구조
각 엔티티 슬라이스는 다음 세그먼트로 구성됩니다:
```
entities/
└── [entity-name]/
├── model/ # 데이터 저장소, 검증 스키마, 상태 관리
├── api/ # 엔티티 관련 API 요청 함수
├── ui/ # 엔티티 시각적 표현 (선택적)
├── lib/ # 엔티티 관련 유틸리티 (선택적)
├── @x/ # 다른 엔티티로부터의 cross-import용 Public API (선택적)
└── index.ts # Public API
```
### 세그먼트 설명
- **model**: 데이터 모델, 타입 정의, 상태 저장소 (Zustand 등)
- **api**: 백엔드 API 호출 함수
- **ui**: 엔티티를 표시하는 UI 컴포넌트 (완전한 UI 블록이 아니라 재사용 가능한 표현)
- **lib**: 엔티티 관련 유틸리티 함수
- **@x**: 다른 엔티티가 이 엔티티를 import할 때 사용하는 명시적 cross-import API
- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
## 예시
```typescript
// entities/user/model/user.ts
export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
// entities/user/api/get-user.ts
import type { User } from '../model/user';
export async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// entities/user/ui/user-card.tsx
import type { User } from '../model/user';
interface UserCardProps {
user: User;
onClick?: () => void;
}
export function UserCard({ user, onClick }: UserCardProps) {
return (
<div onClick={onClick}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}
// entities/user/index.ts
export { type User } from './model/user';
export { getUser } from './api/get-user';
export { UserCard } from './ui/user-card';
```
## 엔티티 간 관계
엔티티가 서로 연결되어 있을 때는 `@x` 표기법을 사용하여 명시적으로 cross-import를 관리하는 것이 권장됩니다:
```typescript
// entities/artist/model/artist.ts
import type { Song } from 'entities/song/@x/artist';
export interface Artist {
name: string;
songs: Array<Song>;
}
// entities/song/@x/artist.ts
export type { Song } from '../model/song';
```
> `@x` 폴더를 사용하면 엔티티 간 연결을 명확하게 만들어 함께 리팩터링이 필요한 코드를 쉽게 찾을 수 있습니다.
## 레이어 의존성 규칙
✅ **허용되는 의존성**:
- `shared` 레이어의 모듈
- 같은 엔티티 슬라이스 내의 다른 세그먼트
- 다른 엔티티 슬라이스 (필요한 경우, `@x` 표기법 권장)
❌ **금지된 의존성**:
- `features` 레이어
- `widgets` 레이어
- `pages` 레이어
- `app` 레이어
## 주의사항
1. **UI 컴포넌트는 완전한 비즈니스 로직을 포함하지 않음**: UI는 주로 props나 slots를 통해 다양한 비즈니스 로직을 연결할 수 있도록 설계
2. **엔티티 간 비즈니스 로직은 상위 레이어에**: 엔티티 간 상호작용의 비즈니스 로직은 Features나 Pages 레이어에서 처리
3. **데이터 관리에 집중**: 복잡한 비즈니스 로직보다는 데이터 모델, 검증, 저장에 집중
-1
View File
@@ -1 +0,0 @@
export { type MenuItem } from "./model/types";
@@ -1,19 +0,0 @@
export interface MenuItem {
menuCd: number;
menuKornNm: string;
menuEngNm: string;
parentId: number | null;
sortSeq: number;
iconNm?: string;
children: MenuItem[] | null;
levelNo: number;
urlPath: string;
menuLnkgTypeCd: string;
menuLnkgSn: number;
menuPath: string;
qrcd: string;
useYn: string;
otptYn: string;
newWndwYn: string;
requiredUrlPath: boolean;
}
-18
View File
@@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
-130
View File
@@ -1,130 +0,0 @@
# Features Layer
**Features**는 앱의 주요 사용자 상호작용과 기능을 구현하는 레이어입니다. 사용자가 실제로 수행하는 작업들로, 주로 비즈니스 엔티티와 관련이 있습니다.
## 목적
- 사용자가 중요하게 생각하는 주요 상호작용 구현
- 재사용 가능한 비즈니스 기능 제공
- 여러 페이지에서 사용되는 기능의 중복 제거
## Feature로 만들어야 하는 경우
> ⚠️ **중요**: 모든 것을 feature로 만들 필요는 없습니다.
Feature로 만드는 것이 좋은 지표:
- **여러 페이지에서 재사용됨**: 예를 들어, 여러 에디터가 있고 모두 댓글 기능이 있다면, 댓글은 재사용 가능한 feature입니다
- **중요한 비즈니스 기능**: 신규 프로젝트 참여자가 Features 폴더를 보고 앱의 주요 기능을 빠르게 파악할 수 있어야 합니다
Feature가 너무 많으면 중요한 기능이 묻히므로, 신규 참여자의 경험을 최적화하여 결정하세요.
## 구조
각 feature 슬라이스는 다음 세그먼트로 구성됩니다:
```
features/
└── [feature-name]/
├── ui/ # 상호작용을 수행하는 UI (폼, 버튼 등)
├── api/ # 액션 수행에 필요한 API 호출 (선택적)
├── model/ # 검증 및 내부 상태 관리 (선택적)
├── lib/ # 유틸리티 함수 (선택적)
├── config/ # feature flags (선택적)
└── index.ts # Public API
```
### 세그먼트 설명
- **ui**: 사용자 상호작용을 위한 UI 컴포넌트 (폼, 모달, 버튼 등)
- **api**: feature 실행에 필요한 API 요청 함수
- **model**: 폼 검증, 내부 상태, 비즈니스 로직
- **lib**: feature 내부에서만 사용되는 유틸리티
- **config**: feature flags, feature별 설정값
- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
## 예시
```typescript
// features/auth-login/ui/login-form.tsx
import { useState } from 'react';
import { loginUser } from '../api/login-user';
import type { LoginCredentials } from '../model/types';
export function LoginForm() {
const [credentials, setCredentials] = useState<LoginCredentials>({
email: '',
password: ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await loginUser(credentials);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={credentials.email}
onChange={(e) => setCredentials({ ...credentials, email: e.target.value })}
/>
<input
type="password"
value={credentials.password}
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
/>
<button type="submit">로그인</button>
</form>
);
}
// features/auth-login/api/login-user.ts
import type { LoginCredentials } from '../model/types';
export async function loginUser(credentials: LoginCredentials) {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
return response.json();
}
// features/auth-login/model/types.ts
export interface LoginCredentials {
email: string;
password: string;
}
// features/auth-login/index.ts
export { LoginForm } from './ui/login-form';
export { loginUser } from './api/login-user';
export type { LoginCredentials } from './model/types';
```
## 레이어 의존성 규칙
✅ **허용되는 의존성**:
- `shared` 레이어의 모듈
- `entities` 레이어의 모듈
- 같은 feature 슬라이스 내의 다른 세그먼트
❌ **금지된 의존성**:
- 다른 `features` 슬라이스 (feature 간 직접 의존 금지)
- `widgets` 레이어
- `pages` 레이어
- `app` 레이어
> 💡 **팁**: 여러 features가 공통 기능을 필요로 한다면, 그 기능을 `shared` 레이어로 이동시키거나 `entities` 레이어에서 관리하세요.
## 명명 규칙
- 소문자와 대시 사용: `auth-login`, `post-create`, `comment-edit`
- 동사 형태 권장: 사용자 액션을 나타내는 이름 사용
- 명확하고 구체적으로: 기능의 목적이 명확하게 드러나도록
## 주의사항
1. **하나의 기능에만 집중**: 각 feature는 단일 사용자 시나리오를 구현해야 합니다
2. **재사용성 고려**: 여러 페이지에서 사용될 수 있도록 설계
3. **독립성 유지**: 다른 features에 의존하지 않고 독립적으로 작동
4. **적절한 추상화**: 너무 많은 feature를 만들지 말고, 중요한 기능 위주로 구성
-8
View File
@@ -1,8 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
transpilePackages: ["@common/assets"],
};
export default nextConfig;
-36
View File
@@ -1,36 +0,0 @@
{
"name": "as",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fsd": "steiger ."
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/components": "workspace:*",
"@common/network": "workspace:*",
"@hookform/resolvers": "^4.1.3",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"axios": "^1.8.1",
"dayjs": "^1.11.13",
"next": "15.3.8",
"react-hook-form": "^7.54.2",
"zod": "^3.24.2"
},
"devDependencies": {
"eslint": "^9.0.0",
"eslint-config-next": "15.3.8",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"steiger": "^0.5.11",
"tailwindcss": "^3.4.14",
"typescript": "^5.7.2"
}
}
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
-149
View File
@@ -1,149 +0,0 @@
# Shared Layer
**Shared**는 애플리케이션의 기반을 형성하는 레이어입니다. 외부 세계(백엔드, 서드파티 라이브러리, 환경)와의 연결을 생성하고, 높은 응집력을 가진 자체 라이브러리를 정의하는 곳입니다.
## 목적
- 외부 시스템 및 라이브러리와의 통합
- 프로젝트 전체에서 사용되는 공통 코드 제공
- 비즈니스 로직이 없는 재사용 가능한 유틸리티 및 UI 제공
- 글로벌 설정 및 환경 변수 관리
## 특징
- **슬라이스 없음**: Shared 레이어는 비즈니스 도메인이 없으므로 슬라이스로 나뉘지 않습니다
- **세그먼트 구성**: 기술적 목적에 따라 세그먼트로 직접 구성됩니다
- **자유로운 import**: Shared 내의 모든 파일은 서로를 참조하고 import할 수 있습니다
## 구조
```
shared/
├── ui/ # 애플리케이션 UI Kit
│ ├── button/
│ ├── input/
│ ├── modal/
│ └── index.ts
├── api/ # API 클라이언트 및 백엔드 엔드포인트 함수
│ ├── client.ts
│ └── index.ts
├── lib/ # 내부 라이브러리 모음
│ ├── dates/ # 날짜 관련 유틸리티
│ ├── colors/ # 색상 관련 유틸리티
│ ├── text/ # 텍스트 처리 유틸리티
│ └── index.ts
├── config/ # 환경 변수, 글로벌 feature flags
│ ├── env.ts
│ └── index.ts
├── types/ # 공통 타입 정의
│ └── index.ts
├── i18n/ # 번역 설정, 글로벌 번역 문자열
│ └── index.ts
└── routes/ # 라우트 상수 또는 패턴
└── index.ts
```
## 세그먼트 설명
### 📁 ui
애플리케이션의 UI Kit로, 비즈니스 로직을 포함하지 않는 재사용 가능한 UI 컴포넌트들입니다.
- Button, Input, Modal 같은 기본 컴포넌트
- 비즈니스 로직은 없지만 UI 로직은 포함 가능 (예: Autocomplete, SearchBar)
- 비즈니스 테마 컴포넌트도 허용 (예: 회사 로고, 페이지 레이아웃)
### 📁 api
API 클라이언트 및 특정 백엔드 엔드포인트 요청 함수입니다.
- HTTP 클라이언트 설정 (Axios, Fetch 등)
- 인증 인터셉터
- 공통 에러 핸들링
### 📁 lib
내부 라이브러리 모음으로, 각 라이브러리는 **하나의 관심사**에 집중해야 합니다.
> ⚠️ **중요**: `helpers`나 `utils` 폴더처럼 덤프 폴더로 만들지 마세요. 각 라이브러리는 명확한 초점 영역(예: dates, colors, text)을 가져야 하며, README로 문서화해야 합니다.
### 📁 config
환경 변수, 글로벌 feature flags 및 앱 전체 설정입니다.
### 📁 types
프로젝트 전체에서 사용되는 공통 타입 정의입니다.
### 📁 i18n
번역 설정 코드 및 글로벌 번역 문자열입니다.
### 📁 routes
라우트 상수 또는 라우트 매칭 패턴입니다.
## 예시
```typescript
// shared/ui/button/button.tsx
interface ButtonProps {
children: React.ReactNode;
variant?: 'primary' | 'secondary';
onClick?: () => void;
}
export function Button({ children, variant = 'primary', onClick }: ButtonProps) {
return (
<button
className={variant === 'primary' ? 'bg-blue-500' : 'bg-gray-500'}
onClick={onClick}
>
{children}
</button>
);
}
// shared/api/client.ts
import axios from 'axios';
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 10000,
});
// shared/lib/dates/format-date.ts
import dayjs from 'dayjs';
export function formatDate(date: Date, format: string = 'YYYY-MM-DD'): string {
return dayjs(date).format(format);
}
// shared/config/env.ts
export const ENV = {
apiUrl: process.env.NEXT_PUBLIC_API_URL!,
isDev: process.env.NODE_ENV === 'development',
} as const;
```
## 레이어 의존성 규칙
✅ **허용되는 의존성**:
- 같은 `shared` 레이어 내의 다른 세그먼트 (자유로운 cross-import)
- 외부 라이브러리 및 패키지
❌ **금지된 의존성**:
- `entities` 레이어
- `features` 레이어
- `widgets` 레이어
- `pages` 레이어
- `app` 레이어
> 💡 **원칙**: Shared는 가장 낮은 레이어이므로 상위 레이어에 의존하지 않습니다.
## 세그먼트 명명 규칙
세그먼트 이름은 **내용의 목적**을 설명해야 하며, **본질**을 설명하면 안 됩니다.
❌ **나쁜 예시**: `components`, `hooks`, `types` (본질을 설명)
✅ **좋은 예시**: `ui`, `api`, `lib/dates`, `config` (목적을 설명)
## 주의사항
1. **비즈니스 로직 없음**: Shared는 순수한 기술적 코드만 포함
2. **높은 재사용성**: 앱 전체에서 사용될 수 있어야 함
3. **명확한 라이브러리 구조**: `lib` 내부는 관심사별로 명확하게 분리
4. **문서화**: 각 라이브러리는 README로 사용법과 목적을 문서화
-9
View File
@@ -1,9 +0,0 @@
import { Client } from "@common/network";
import { PROXY_HOST, SITE_ID } from "@/shared/config/env";
const BASE_URL = `${PROXY_HOST}/server`;
export const apiClient = Client.getInstance({
baseURL: BASE_URL,
siteId: SITE_ID,
});
-1
View File
@@ -1 +0,0 @@
export { apiClient } from "./client";
-11
View File
@@ -1,11 +0,0 @@
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST =
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "AS";
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN =
process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
export const REFRESH_TOKEN =
process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
-1
View File
@@ -1 +0,0 @@
export * from "./env";
-12
View File
@@ -1,12 +0,0 @@
import dayjs from "dayjs";
import "dayjs/locale/ko";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
dayjs.locale("ko");
export { dayjs };
-2
View File
@@ -1,2 +0,0 @@
export { queryClient } from "./query-client";
export { dayjs } from "./dayjs";
-11
View File
@@ -1,11 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 60 * 1000,
},
},
});
-29
View File
@@ -1,29 +0,0 @@
export interface BaseEntity {
id: string;
createdAt: string;
updatedAt: string;
}
export interface PaginationParams {
page: number;
limit: number;
}
export interface PaginationResponse<T> {
data: T[];
total: number;
page: number;
limit: number;
}
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export interface ApiError {
message: string;
code?: string;
status?: number;
}
-1
View File
@@ -1 +0,0 @@
export { PageLayout } from "./page-layout";
-35
View File
@@ -1,35 +0,0 @@
"use client";
import { PageTitle } from "@common/components";
const mockCurrentMenuInfo = {
menuCd: 0,
menuKornNm: "메인",
menuEngNm: "Main",
parentId: null,
sortSeq: 0,
children: null,
levelNo: 0,
urlPath: "/",
menuLnkgTypeCd: "",
menuLnkgSn: 0,
menuPath: "메인",
qrcd: "",
useYn: "Y",
otptYn: "Y",
newWndwYn: "N",
requiredUrlPath: false,
};
function PageLayout({ children }: { children: React.ReactNode }) {
const menuPaths = mockCurrentMenuInfo.menuPath.split(">").map((path) => path.trim());
return (
<div className="bg-container h-auto min-h-full pb-2">
<PageTitle title={mockCurrentMenuInfo.menuKornNm} menuPaths={menuPaths} />
{children}
</div>
);
}
export { PageLayout };
-29
View File
@@ -1,29 +0,0 @@
import type { Config } from "tailwindcss";
import presetConfig from "../../tailwind.config.preset";
const config: Config = {
presets: [presetConfig],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./shared/**/*.{js,ts,jsx,tsx,mdx}",
"./entities/**/*.{js,ts,jsx,tsx,mdx}",
"./features/**/*.{js,ts,jsx,tsx,mdx}",
"./widgets/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"../../packages/common/components/dist/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
fontFamily: {
sans: ["var(--font-noto-sans-kr)", "Noto Sans KR", "sans-serif"],
},
},
},
plugins: [],
};
export default config;
-35
View File
@@ -1,35 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"],
"@/app/*": ["./app/*"],
"@/shared/*": ["./shared/*"],
"@/entities/*": ["./entities/*"],
"@/features/*": ["./features/*"],
"@/widgets/*": ["./widgets/*"],
"@/pages/*": ["./pages/*"],
"@common/components": ["../../packages/common/components/dist/"],
"@common/network": ["../../packages/common/network/"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
-135
View File
@@ -1,135 +0,0 @@
# Widgets Layer
**Widgets**는 큰 규모의 독립적인 UI 블록을 위한 레이어입니다. 여러 features와 entities를 조합하여 완전한 기능을 제공하는 복합 컴포넌트를 담습니다.
## 목적
- 큰 규모의 독립적인 UI 블록 구성
- 여러 features와 entities를 하나의 응집력 있는 UI로 결합
- 여러 페이지에서 재사용되는 복잡한 UI 제공
- 페이지 레벨 레이아웃 및 섹션 관리
## Widget으로 만들어야 하는 경우
Widget은 다음 조건에 해당할 때 사용하세요:
✅ **Widget으로 만들기 좋은 경우**:
- 여러 페이지에서 재사용되는 큰 UI 블록
- 페이지에 여러 개의 큰 독립적 블록이 있고, 이것이 그 중 하나인 경우
- 페이지 레벨 레이아웃 컴포넌트 (nested routing 시스템 사용 시)
❌ **Widget으로 만들지 말아야 하는 경우**:
- UI 블록이 페이지의 대부분을 차지하고 재사용되지 않는 경우 → 페이지 슬라이스 내부에 직접 배치
- 작은 재사용 컴포넌트 → `shared/ui`에 배치
## 구조
각 widget 슬라이스는 다음 세그먼트로 구성됩니다:
```
widgets/
└── [widget-name]/
├── ui/ # 위젯의 메인 UI 컴포넌트
├── model/ # 위젯 내부 상태 관리 (선택적)
├── api/ # 위젯 전용 API 호출 (선택적)
├── lib/ # 위젯 내부 유틸리티 (선택적)
└── index.ts # Public API
```
### 세그먼트 설명
- **ui**: 여러 features와 entities를 조합한 위젯 UI
- **model**: 위젯 내부 상태 및 로직 (선택적)
- **api**: 위젯이 필요로 하는 데이터 fetching (선택적)
- **lib**: 위젯 내부에서만 사용되는 헬퍼 함수 (선택적)
- **index.ts**: 슬라이스의 Public API (외부에서 사용할 수 있는 것만 export)
## 예시
```typescript
// widgets/header/ui/header.tsx
import { Logo } from 'shared/ui/logo';
import { Navigation } from 'shared/ui/navigation';
import { UserMenu } from 'features/user-menu';
import { NotificationBell } from 'features/notifications';
export function Header() {
return (
<header>
<Logo />
<Navigation />
<NotificationBell />
<UserMenu />
</header>
);
}
// widgets/product-list/ui/product-list.tsx
import { useProducts } from '../api/use-products';
import { ProductCard } from 'entities/product';
import { AddToCartButton } from 'features/cart-add-item';
export function ProductList() {
const { data: products, isLoading } = useProducts();
if (isLoading) return <div>로딩 중...</div>;
return (
<div>
{products.map((product) => (
<div key={product.id}>
<ProductCard product={product} />
<AddToCartButton productId={product.id} />
</div>
))}
</div>
);
}
// widgets/product-list/api/use-products.ts
import { useQuery } from '@tanstack/react-query';
import { getProducts } from 'entities/product';
export function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: getProducts,
});
}
// widgets/product-list/index.ts
export { ProductList } from './ui/product-list';
```
## 레이어 의존성 규칙
✅ **허용되는 의존성**:
- `shared` 레이어의 모듈
- `entities` 레이어의 모듈
- `features` 레이어의 모듈
- 같은 widget 슬라이스 내의 다른 세그먼트
❌ **금지된 의존성**:
- 다른 `widgets` 슬라이스 (widget 간 직접 의존 금지)
- `pages` 레이어
- `app` 레이어
## 명명 규칙
- 소문자와 대시 사용: `header`, `sidebar`, `product-list`
- 명사 형태 권장: UI 블록의 성격을 나타내는 이름 사용
- 구체적으로: 위젯의 역할이 명확하게 드러나도록
## Nested Routing 사용 시
Remix 같은 nested routing 시스템을 사용한다면:
- Widgets 레이어를 flat routing의 Pages 레이어처럼 사용 가능
- 완전한 라우터 블록 생성 (데이터 fetching, loading states, error boundaries 포함)
- 페이지 레이아웃을 이 레이어에 저장 가능
## 주의사항
1. **독립성 유지**: 각 위젯은 독립적으로 작동 가능해야 합니다
2. **적절한 크기**: 너무 작은 컴포넌트는 `shared/ui`에, 재사용되지 않으면 `pages`에 배치
3. **응집력**: 관련된 features와 entities를 의미 있게 조합
4. **재사용 최적화**: 여러 페이지에서 동일한 방식으로 사용될 수 있도록 설계
-5
View File
@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+6 -13
View File
@@ -1,14 +1,7 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3001
NEXT_PUBLIC_PROXY_HOST=http://localhost:3001
NEXT_PUBLIC_SITE_ID=CM
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:13000
# NEXT_PUBLIC_SERVER_API_URL=http://localhost:8000
NEXT_PUBLIC_SERVER_API_URL=http://dev.onasys.co.kr:8000
NEXT_PUBLIC_LINK_BACKEND_API_HOST=http://rsone.iptime.org:10014
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_SERVER_API_URL2=http://con.mind-one.co.kr:11083
NEXT_PUBLIC_SERVER_API_URL3=https://apigateway.apps.rsonesoft.com
NEXT_PUBLIC_SN=https://devlion.hasuon.co.kr
NEXT_PUBLIC_WR=https://devtiger.hasuon.co.kr
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000
NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
+10
View File
@@ -0,0 +1,10 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:13000
NEXT_PUBLIC_SERVER_API_URL=http://dev.onasys.co.kr:8000
NEXT_PUBLIC_LINK_BACKEND_API_HOST=http://rsone.iptime.org:10014
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_SERVER_API_URL2=http://con.mind-one.co.kr:11083/
+7
View File
@@ -0,0 +1,7 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000
NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
BIN
View File
Binary file not shown.
-2
View File
@@ -10,8 +10,6 @@
!.yarn/releases
!.yarn/versions
pnpm_lock.yaml
# testing
/coverage
+12 -28
View File
@@ -36,15 +36,9 @@ async function handler(
}
try {
// GET 요청이 아닐 때만 body 파싱
if (method !== "GET" && !isRefreshHandler) {
try {
body = await request.json();
console.log(`[Proxy] Request body:`, body);
} catch {
// body가 없을 수 있음
body = {};
}
if (!isRefreshHandler) {
body = await request.json();
console.log(`[Proxy] Request body:`, body);
}
const targetUrl = `${SERVER_API_URL}/${params.url.join("/")}`;
@@ -53,7 +47,7 @@ async function handler(
const result = await fetch(targetUrl, {
method,
headers,
body: method !== "GET" ? JSON.stringify(body) : undefined,
body: JSON.stringify(body),
});
console.log(`[Proxy] Response status: ${result.status}`);
@@ -61,14 +55,12 @@ async function handler(
// 2FA 관련 엔드포인트인 경우 응답 본문도 확인
const is2FAEndpoint = params.url.includes("2fa") || params.url.includes("auth");
// 배너 API는 인증 없이 호출 가능
const isBannerEndpoint = params.url.includes("bnrs");
let responseData = {};
if (is2FAEndpoint || isBannerEndpoint) {
if (is2FAEndpoint) {
try {
responseData = await result.json();
console.log(`[Proxy] ${is2FAEndpoint ? '2FA' : 'Banner'} Response data:`, responseData);
console.log(`[Proxy] 2FA Response data:`, responseData);
} catch {
// JSON 파싱 실패 시 빈 객체 사용
console.log(`[Proxy] Failed to parse JSON response`);
@@ -82,15 +74,15 @@ async function handler(
console.log(`[Proxy] Tokens - accessToken: ${!!accessToken}, userId: ${!!userId}, refreshToken: ${!!refreshToken}`);
console.log(`[Proxy] Available headers:`, Array.from(result.headers.keys()));
// 2FA 또는 배너 엔드포인트이고 실패한 경우 실제 오류 메시지 반환
if (!result.ok && (is2FAEndpoint || isBannerEndpoint)) {
console.log(`[Proxy] ${is2FAEndpoint ? '2FA' : 'Banner'} endpoint failed, returning actual error`);
// 2FA 엔드포인트이고 실패한 경우 실제 오류 메시지 반환
if (!result.ok && is2FAEndpoint) {
console.log(`[Proxy] 2FA endpoint failed, returning actual error`);
return NextResponse.json(responseData, { status: result.status });
}
// 2FA 또는 배너 엔드포인트이고 성공한 경우 (토큰이 헤더에 없을 수 있음)
if (result.ok && (is2FAEndpoint || isBannerEndpoint)) {
console.log(`[Proxy] ${is2FAEndpoint ? '2FA' : 'Banner'} endpoint succeeded, returning response data`);
// 2FA 엔드포인트이고 성공한 경우 (토큰이 헤더에 없을 수 있음)
if (result.ok && is2FAEndpoint) {
console.log(`[Proxy] 2FA endpoint succeeded, returning response data`);
return NextResponse.json(responseData, { status: result.status });
}
@@ -151,11 +143,3 @@ export async function PUT(
const params = await props.params;
return handler(request, params, "PUT");
}
export async function GET(
request: Request,
props: { params: Promise<{ url: string[] }> },
) {
const params = await props.params;
return handler(request, params, "GET");
}
-5
View File
@@ -12,11 +12,6 @@ const compat = new FlatCompat({
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
languageOptions: {
parserOptions: {
tsconfigRootDir: __dirname,
},
},
rules: {
"react-hooks/exhaustive-deps": "off",
},
+42 -54
View File
@@ -17,8 +17,8 @@ const serverApiUrl =
const serverApiUrl3 =
process.env.NEXT_PUBLIC_SERVER_API_URL3 ?? serverApiUrl;
// const serverApiUrl4 =
// process.env.NEXT_PUBLIC_SERVER_API_URL4 ?? serverApiUrl;
const serverApiUrl4 =
process.env.NEXT_PUBLIC_SERVER_API_URL4 ?? serverApiUrl;
const serverApiUrl5 =
process.env.NEXT_PUBLIC_SERVER_API_URL5 ?? serverApiUrl;
@@ -33,6 +33,19 @@ export function middleware(req: NextRequest) {
`[Request]\n Original Pathname: ${pathname}`,
];
if (pathname.startsWith("/server")) {
const queryParams = searchParams.toString();
const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
queryParams ? `?${queryParams}` : ""
}`;
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname.startsWith("/attach-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
@@ -42,27 +55,6 @@ export function middleware(req: NextRequest) {
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
if (pathname.startsWith("/server/sewerage-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
// const decodedQuery = toDecodedQueryString(searchParams);
const newUrl = `${serverApiUrl2}${pathname.replace("/server/", "/")}${searchParams ? `?${searchParams}` : ""}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
@@ -72,7 +64,8 @@ export function middleware(req: NextRequest) {
});
}
if (pathname.startsWith("/reuse-service")) {
if (pathname.startsWith("/sewerage-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
@@ -90,12 +83,11 @@ export function middleware(req: NextRequest) {
});
}
if (pathname.startsWith("/server/operation-service")) {
if (pathname.startsWith("/reuse-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
// const newUrl = `${serverApiUrl2}${pathname.replace("/server/", "")}`;
const newUrl = `${serverApiUrl2}${pathname.replace("/server/", "/")}${searchParams ? `?${searchParams}` : ""}`;
const newUrl = `${serverApiUrl3}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
@@ -109,12 +101,11 @@ export function middleware(req: NextRequest) {
});
}
if (pathname.startsWith("/server/ic-service")) {
if (pathname.startsWith("/operation-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
const newUrl = `${serverApiUrl3}${pathname.replace("/server/", "/")}`;
const newUrl = `${serverApiUrl4}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
@@ -128,12 +119,29 @@ export function middleware(req: NextRequest) {
});
}
if (pathname.startsWith("/server/dc-service")) {
if (pathname.startsWith("/ic-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
const newUrl = `${serverApiUrl5}${pathname}`;
const newUrl = `${serverApiUrl3}${pathname.replace("/server/", "/")}${searchParams ? `?${searchParams}` : ""}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
if (pathname.startsWith("/dc-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
const newUrl = `${serverApiUrl5}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
@@ -155,25 +163,11 @@ export function middleware(req: NextRequest) {
logMessages.push(` User Service Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname.startsWith("/server")) {
const queryParams = searchParams.toString();
const newUrl = `${serverApiUrl}${pathname.replace("/server", "/")}${
queryParams ? `?${queryParams}` : ""
}`;
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname === "/") {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
@@ -184,11 +178,5 @@ export function middleware(req: NextRequest) {
}
export const config = {
matcher: ["/server/:path*",
"/attach-service/:path*",
"/user-service/:path*",
"/sewerage-service/:path*",
"/reuse-service/:path*",
"/operation-service/:path*",
"/"],
matcher: ["/server/:path*", "/attach-service/:path*", "/user-service/:path*", "/"],
};
+6 -14
View File
@@ -3,22 +3,10 @@ import type { NextConfig } from "next";
const subDomain =
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
const backendUrl = process.env.NEXT_PUBLIC_SERVER_API_URL || "http://192.168.0.102:8000";
const nextConfig: NextConfig = {
output: "standalone",
experimental: {
proxyTimeout: 600000,
},
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
logging: {
fetches: {
fullUrl: true,
},
},
async rewrites() {
return {
beforeFiles: [
@@ -39,6 +27,10 @@ const nextConfig: NextConfig = {
source: "/cms/:path*",
destination: `${subDomain}/cms/:path*`,
},
{
source: "/user-service/:path*",
destination: `${backendUrl}/user-service/:path*`,
},
],
fallback: [],
};
+10 -10
View File
@@ -25,16 +25,16 @@
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"@tinymce/tinymce-react": "^5.1.1",
"@tinymce/tinymce-react": "^6.1.0",
"axios": "^1.8.1",
"chart.js": "^4.4.8",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.3.8",
"next": "15.3.0",
"next-qrcode": "^2.5.1",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
@@ -47,17 +47,17 @@
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.0.0",
"@eslint/eslintrc": "^3",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9.0.0",
"eslint-config-next": "15.3.8",
"postcss": "^8.4.49",
"eslint": "^9",
"eslint-config-next": "15.1.7",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"steiger": "^0.5.5",
"tailwindcss": "^3.4.14",
"typescript": "^5.7.2"
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

-317
View File
@@ -73,32 +73,12 @@
padding: 0 !important;
}
.card_paddingnone {
padding: 0 !important;
}
.card_nomargin {
width: 100%;
margin: 0 !important;
padding: 0 !important;
}
.card_title {
padding: 1rem;
margin-bottom: 1rem;
}
.card_title .leftArea {
display: flex;
align-items: center;
}
.card_title h4 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.topline {
border-top: 1px solid #ccc;
}
@@ -147,300 +127,3 @@
border-bottom-color: #3f4042;
}
}
/* 자료현황 스타일 */
.mid_info_area {
padding: 10px 5px;
height: 220px;
position: relative;
margin-top: 0;
}
.mi_list_swiper {
height: 100%;
width: 100%;
}
.mi_list {
height: 100%;
}
.ml_box {
position: relative;
width: 90%;
max-width: 90%;
height: 150px;
padding: 12px;
margin: 0 8px;
display: flex;
border-radius: 12px;
background: #f8f9fa;
border: 1px solid #e9ecef;
box-sizing: border-box;
transition: all 0.3s ease;
}
.ml_box:hover {
background: #e3f2fd;
border-color: #2196f3;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.mb_section {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
}
.tit {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.tit i {
font-size: 20px;
margin-right: 10px;
color: #2196f3;
}
.tt {
font-size: 16px;
font-weight: 600;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.info_row {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.info_item {
text-align: center;
width: 100%;
}
.info_label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.info_value {
font-size: 18px;
font-weight: bold;
color: #2196f3;
line-height: 1;
}
/* Swiper 네비게이션 스타일 */
.mi_list_swiper .swiper-button-next,
.mi_list_swiper .swiper-button-prev {
color: #2196f3;
width: 30px;
height: 30px;
margin-top: -15px;
}
.mi_list_swiper .swiper-button-next:after,
.mi_list_swiper .swiper-button-prev:after {
font-size: 16px;
}
.mi_list_swiper .swiper-pagination {
bottom: 5px;
}
.mi_list_swiper .swiper-pagination-bullet {
background: #2196f3;
opacity: 0.3;
}
.mi_list_swiper .swiper-pagination-bullet-active {
opacity: 1;
}
/* 특별시 스타일 */
.ml_box.special-city {
background: linear-gradient(135deg, #ff6b6b, #ff8e8e);
border-color: #ff4757;
color: white;
}
.ml_box.special-city .tt {
color: white;
font-weight: 700;
}
.ml_box.special-city .info_label {
color: rgba(255, 255, 255, 0.8);
}
.ml_box.special-city .info_value {
color: white;
}
.ml_box.special-city:hover {
background: linear-gradient(135deg, #ff5252, #ff7979);
transform: translateY(-3px);
box-shadow: 0 6px 12px rgba(255, 107, 107, 0.3);
}
/* 광역시 스타일 */
.ml_box.metropolitan-city {
background: linear-gradient(135deg, #4facfe, #00f2fe);
border-color: #2196f3;
color: white;
}
.ml_box.metropolitan-city .tt {
color: white;
font-weight: 600;
}
.ml_box.metropolitan-city .info_label {
color: rgba(255, 255, 255, 0.8);
}
.ml_box.metropolitan-city .info_value {
color: white;
}
.ml_box.metropolitan-city:hover {
background: linear-gradient(135deg, #2196f3, #21cbf3);
transform: translateY(-3px);
box-shadow: 0 6px 12px rgba(33, 150, 243, 0.3);
}
/* 광역도 스타일 */
.ml_box.province {
background: linear-gradient(135deg, #a8edea, #fed6e3);
border-color: #00bcd4;
color: #333;
}
.ml_box.province .tt {
color: #333;
font-weight: 600;
}
.ml_box.province .info_label {
color: #666;
}
.ml_box.province .info_value {
color: #00bcd4;
}
.ml_box.province:hover {
background: linear-gradient(135deg, #00bcd4, #26c6da);
color: white;
transform: translateY(-3px);
box-shadow: 0 6px 12px rgba(0, 188, 212, 0.3);
}
.ml_box.province:hover .tt,
.ml_box.province:hover .info_label,
.ml_box.province:hover .info_value {
color: white;
}
/* 네이버 스타일 로그인 박스 */
.login_info_box {
padding: 20px;
background: #fff;
border: 1px solid #e1e5e9;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.login_header {
display: flex;
align-items: center;
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px solid #f0f0f0;
width: 100%;
}
.login_avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
margin-right: 12px;
border: 2px solid #e1e5e9;
}
.login_avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.login_user_info {
flex: 1;
}
.user_name {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 2px;
}
.user_status {
font-size: 12px;
color: #03c75a;
font-weight: 500;
}
.login_actions {
width: 100%;
display: flex;
gap: 8px;
}
.login_btn {
flex: 1;
height: 40px;
background: #03c75a;
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.login_btn:hover {
background: #02b351;
transform: translateY(-1px);
}
.signup_btn {
flex: 1;
height: 40px;
background: #fff;
color: #03c75a;
border: 1px solid #03c75a;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.signup_btn:hover {
background: #03c75a;
color: #fff;
transform: translateY(-1px);
}
+10 -4
View File
@@ -5,15 +5,16 @@ import { useRouter } from "next/navigation";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
import { isAtLoginRedirectUrl } from "@common/network";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
import { LoginApi } from "../api/login-service";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
if (isAtLoginRedirectUrl("/auth/login")) {
// 로그인 페이지에서는 인증 체크를 건너뜀
if (window.location.pathname === '/auth/login') {
return;
}
@@ -34,8 +35,13 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
await authCheck();
} catch (error) {
console.error(error);
setUserInfo(null);
if (!isAtLoginRedirectUrl("/auth/login")) {
try {
await LoginApi.silentRefresh();
await authCheck();
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
setUserInfo(null);
router.push("/auth/login");
}
}
+2 -27
View File
@@ -1,4 +1,4 @@
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, REFRESH_TOKEN } from "@/shared/config";
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
import { getCookie, setCookie, removeCookie } from "@/shared/lib";
export class TokenManager {
@@ -19,37 +19,14 @@ export class TokenManager {
}
public setToken(key: string, value: string): void {
// 환경에 따른 도메인 설정
const domain = this.getCookieDomain();
setCookie(key, value, {
secure: window.location.protocol === "https:",
sameSite: "lax",
domain: domain,
path: "/", // 모든 경로에서 접근 가능
});
}
private getCookieDomain(): string | undefined {
const hostname = window.location.hostname;
// 개발 환경에서는 localhost 도메인 사용
if (hostname === "localhost" || hostname === "127.0.0.1") {
return undefined; // localhost에서는 도메인 설정하지 않음
}
// 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
if (hostname.includes("hasuon.or.kr")) {
return ".hasuon.or.kr";
}
// 기타 환경에서는 현재 호스트명 사용
return hostname;
}
public removeToken(key: string): void {
const domain = this.getCookieDomain();
removeCookie(key, { domain, path: "/" });
removeCookie(key);
}
public setupRefreshTimer(
@@ -76,8 +53,6 @@ export class TokenManager {
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
this.removeToken(REFRESH_TOKEN);
this.removeToken(ACCESS_TOKEN);
}
}
+1 -14
View File
@@ -11,11 +11,9 @@ 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 {
@@ -30,22 +28,11 @@ 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> {
const response = await client.get<MyPostList>(
`${ENDPOINT.BOARD_SERVICE.MY_POSTS}`,
`${ENDPOINT.BOARD_SERVICE.POSTS}/mypost`,
{ params },
);
return response.data;
-2
View File
@@ -4,13 +4,11 @@ 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,20 +12,6 @@ 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;
@@ -113,7 +99,6 @@ 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>;
@@ -145,11 +145,11 @@ function LoginPage() {
<div className="left_box fx">
<div className="text">
<h1>
유역하수도통합플랫폼
유역하수도 통합플랫폼
<br />
<span>하수도 포탈</span>
<span>통합관리시스템</span>
</h1>
<p>유역하수도통합플랫폼 하수도 포탈에 오신 것을 환영합니다.</p>
<p>유역하수도 통합플랫폼에 오신 것을 환영합니다.</p>
</div>
<div className="img">
<Image src={images.logo} width={229} height={46} alt="logo" />
@@ -1,54 +0,0 @@
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> => {
try {
const response = await client.get<Blob>(
`${ENDPOINT.ATTACH_SERVICE.IMAGES}/atflCd/${atflCd}`,
{
responseType: "blob",
},
);
const blob = response.data;
const url = URL.createObjectURL(blob);
return url;
} catch (error: unknown) {
const err = error as
| { status?: number; message?: string; response?: { status?: number } }
| Error
| undefined;
const status = err && typeof err === "object" && "status" in err
? err.status
: err && typeof err === "object" && "response" in err && err.response
? err.response.status
: undefined;
const message = err instanceof Error
? err.message
: err && typeof err === "object" && "message" in err
? String(err.message)
: String(error);
return "";
}
};
@@ -0,0 +1,14 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { Dashboard, DashboardService } from "../model/dashboard-interface";
export class DashboardApiService implements DashboardService {
public async getDashboard(): Promise<Dashboard> {
const response = await client.get<Dashboard>(
ENDPOINT.CMS_SERVICE.DASHBOARD,
);
return response.data;
}
}
export const DashboardApi = new DashboardApiService();
@@ -1,48 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageableList } from "@/shared/model";
export interface DataStatus {
ctpvgnsvycnt: number;
filecnt: number;
ctpvnm: string;
}
export const getDataStatusList = async (): Promise<PageableList<DataStatus>> => {
const response = await client.get<DataStatus[]>(
`${ENDPOINT.DC_SERVICE.DATA_STATUS}`,
);
const data = response.data;
console.log("getDataStatusList data:", data);
return {
content: data as DataStatus[],
pageable: {
sort: {
sorted: false,
unsorted: true,
empty: true,
},
offset: 0,
pageNumber: 0,
pageSize: data.length as number,
paged: false,
unpaged: true,
},
sort: {
sorted: false,
unsorted: true,
empty: true,
},
totalPages: 1,
totalElements: data.length,
number: 0,
numberOfElements: data.length,
size: data.length,
first: true,
last: true,
empty: data.length === 0,
};
};
@@ -1,376 +0,0 @@
// apps/cm/src/pages/dashboard/api/fo-service.ts
import { client } from "@/shared/api";
import { createStore } from "zustand/vanilla";
import { create } from "zustand";
import { ENDPOINT } from "@/shared/config";
import type { OperWqltContent, OperFcltContent, ReuseStatusContent, OperationService
} from "../model/fo-interface";
import type { mapOperFcltItem, mapOperFcltType } from "../model/fo-interface";
import type { ReuseMapContent, mapReuseMapType } from "../model/fo-interface";
// 기준년도 조회
export type YearState = {
year: string | null;
};
export type YearStateActions = {
setYear: (year: string | null) => void;
};
export type YearStore = YearState & YearStateActions;
export const defaultYearInitState: YearState = {
year: null,
};
export const createYearStore = (initState: YearState = defaultYearInitState) =>
createStore<YearStore>()((set) => ({
...initState,
setYear: (year) => set(() => ({ year })),
}));
export const yearStore = createYearStore();
// 유역
export type BasinState = {
selectedBasin: string;
};
export type BasinStateActions = {
setSelectedBasin: (selectedBasin: string ) => void;
};
export type BasinStore = BasinState & BasinStateActions;
export const defaultBasinInitState: BasinState = {
selectedBasin: "한강",
};
export const createBasinStore = (initState: BasinState = defaultBasinInitState) =>
createStore<BasinStore>()((set) => ({
...initState,
setSelectedBasin: (selectedBasin) => set(() => ({ selectedBasin })),
}));
export const basinStore = createBasinStore();
//수계별 시설운영 개소수 및 가동률
export type OperFcltState = {
operFcltStatus: OperFcltContent[] | null;
};
export type OperFcltStateActions = {
setOperFcltStatus: (operFcltStatus: OperFcltContent[] | null) => void;
};
export type OperFcltStore = OperFcltState & OperFcltStateActions;
// const initialOperFclt: OperFcltContent[]= [
// {
// "cnt": 710,
// "fctCpc": 2911609.0,
// "rvbsn": "금강",
// "gubun": "R"
// },
// {
// "cnt": 1032,
// "fctCpc": 7506047.0,
// "rvbsn": "낙동강",
// "gubun": "R"
// },
// {
// "cnt": 1261,
// "fctCpc": 2061610.0,
// "rvbsn": "영산강",
// "gubun": "R"
// },
// {
// "cnt": 983,
// "fctCpc": 1.3229809E7,
// "rvbsn": "한강",
// "gubun": "R"
// }
// ];
export const defaultInitOperFcltState: OperFcltState = {
// operFcltStatus: initialOperFclt,
operFcltStatus: null,
};
export const createOperFcltStore = (initState: OperFcltState = defaultInitOperFcltState) =>
createStore<OperFcltStore>()((set) => ({
...initState,
setOperFcltStatus: (operFcltStatus) => set(() => ({ operFcltStatus })),
}));
export const operFcltStore = createOperFcltStore();
export const initailMapOperFcltData: mapOperFcltType = {
colTitle: {cnt:"개소수",rt:"가동률"}, //헤더 영역
data: [
{name: "금강", cnt: 710, rt: 100,},
{name: "한강", cnt: 983, rt: 100,},
{name: "낙동강", cnt: 1032, rt: 100,},
{name: "영산강", cnt: 1261, rt: 100,},
]
};
type mapOperFcltState = {
mapOperFclt: mapOperFcltItem[];
setMapOperFclt: (mapOperFclt: mapOperFcltItem[]) => void;
};
export const useMapOperFcltStore = create<mapOperFcltState>((set) => ({
mapOperFclt: initailMapOperFcltData.data,
setMapOperFclt: (newMapOperFclt) => set({ mapOperFclt: newMapOperFclt }),
}));
export const initailMapReuseMapData: mapReuseMapType = {
colTitle: {reuseAmnt: "하수재이용량",cnt : "시설수" }, //헤더 영역
data: [
{name: "금강", reuseAmnt: 17099, cnt: 3181},
{name: "한강", reuseAmnt: 17099, cnt: 3181},
{name: "낙동강", reuseAmnt: 14745, cnt: 3100},
{name: "영산강", reuseAmnt: 5073, cnt: 1662},
]
};
type mapReuseMapState = {
mapData: ReuseMapContent[];
setMapData: (mapData: ReuseMapContent[]) => void;
};
export const useMapReuseMapStore = create<mapReuseMapState>((set) => ({
mapData: initailMapReuseMapData.data,
setMapData: (newMapOperFclt) => set({ mapData: newMapOperFclt }),
}));
/**
* OperFcltContent[]를 지도 팝업 데이터 형태인 mapOperFcltItem[]으로 변환합니다.
* @param data API 응답 데이터
* @returns 유역별 개소수 및 가동률이 계산된 데이터
*/
function transformToMapData(data: OperFcltContent[]): mapOperFcltItem[] {
const basinData = new Map<string, { operatingCpc: number; totalCpc: number; count: number }>();
// 1. 유역별로 가동/전체 용량 및 개소수 집계
for (const item of data) {
if (!item.rvbsn) continue;
const basin = basinData.get(item.rvbsn) || { operatingCpc: 0, totalCpc: 0, count: 0 };
basin.totalCpc += item.fctCpc;
if (item.gubun === 'R') {
basin.operatingCpc += item.fctCpc;
basin.count = item.cnt; // 'R' 구분 데이터의 개소수를 사용
}
basinData.set(item.rvbsn, basin);
}
// 2. mapOperFcltItem 형태로 변환
const result: mapOperFcltItem[] = [];
for (const [name, { operatingCpc, totalCpc, count }] of basinData.entries()) {
const rt = totalCpc > 0 ? Math.round((operatingCpc / totalCpc) * 100) : 0;
result.push({ name, cnt: count, rt });
}
return result;
}
//수계별 유입ㆍ방류수질 분석
export type OperWqltState = {
operWqltStatus: OperWqltContent[] | [];
};
export type OperWqltStateActions = {
setOperWqltStatus: (operWqltStatus: OperWqltContent[] | []) => void;
};
export type OperWqltStore = OperWqltState & OperWqltStateActions;
const initialWqltData: OperWqltContent[] = [
{ rvbsn: '금강', yr: '2020', sumInflBod: 97975, sumInflToc: 59900, sumInflSs: 103564, sumInflTp: 2879, sumInflTn: 27062, sumEfflBod: 100143, sumEfflToc: 57805, sumEfflSs: 100942, sumEfflTp: 2901, sumEfflTn: 26796, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '금강', yr: '2021', sumInflBod: 97943, sumInflToc: 55043, sumInflSs: 102727, sumInflTp: 2866, sumInflTn: 26237, sumEfflBod: 100143, sumEfflToc: 57805, sumEfflSs: 100942, sumEfflTp: 2901, sumEfflTn: 26796, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '금강', yr: '2022', sumInflBod: 104513, sumInflToc: 58471, sumInflSs: 96534, sumInflTp: 2960, sumInflTn: 27089, sumEfflBod: 100143, sumEfflToc: 57805, sumEfflSs: 100942, sumEfflTp: 2901, sumEfflTn: 26796, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '낙동강', yr: '2020', sumInflBod: 127321, sumInflToc: 79241, sumInflSs: 120184, sumInflTp: 4004, sumInflTn: 35373, sumEfflBod: 128919, sumEfflToc: 69837, sumEfflSs: 123866, sumEfflTp: 4773, sumEfflTn: 36740, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '낙동강', yr: '2021', sumInflBod: 127792, sumInflToc: 61919, sumInflSs: 123625, sumInflTp: 4157, sumInflTn: 35270, sumEfflBod: 128919, sumEfflToc: 69837, sumEfflSs: 123866, sumEfflTp: 4773, sumEfflTn: 36740, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '낙동강', yr: '2022', sumInflBod: 131644, sumInflToc: 68350, sumInflSs: 127789, sumInflTp: 6159, sumInflTn: 39578, sumEfflBod: 128919, sumEfflToc: 69837, sumEfflSs: 123866, sumEfflTp: 4773, sumEfflTn: 36740, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '영산강', yr: '2020', sumInflBod: 141092, sumInflToc: 97019, sumInflSs: 140610, sumInflTp: 14032, sumInflTn: 45880, sumEfflBod: 132293, sumEfflToc: 83642, sumEfflSs: 140673, sumEfflTp: 8419, sumEfflTn: 46126, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '영산강', yr: '2021', sumInflBod: 131004, sumInflToc: 74582, sumInflSs: 141129, sumInflTp: 5682, sumInflTn: 45056, sumEfflBod: 132293, sumEfflToc: 83642, sumEfflSs: 140673, sumEfflTp: 8419, sumEfflTn: 46126, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '영산강', yr: '2022', sumInflBod: 124784, sumInflToc: 79326, sumInflSs: 140281, sumInflTp: 5543, sumInflTn: 47443, sumEfflBod: 132293, sumEfflToc: 83642, sumEfflSs: 140673, sumEfflTp: 8419, sumEfflTn: 46126, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '한강', yr: '2020', sumInflBod: 146258, sumInflToc: 92931, sumInflSs: 151875, sumInflTp: 4151, sumInflTn: 36537, sumEfflBod: 152099, sumEfflToc: 84360, sumEfflSs: 153111, sumEfflTp: 8443, sumEfflTn: 35571, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '한강', yr: '2021', sumInflBod: 150983, sumInflToc: 77755, sumInflSs: 153838, sumInflTp: 4098, sumInflTn: 34946, sumEfflBod: 152099, sumEfflToc: 84360, sumEfflSs: 153111, sumEfflTp: 8443, sumEfflTn: 35571, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '한강', yr: '2022', sumInflBod: 159057, sumInflToc: 82394, sumInflSs: 153619, sumInflTp: 17080, sumInflTn: 35231, sumEfflBod: 152099, sumEfflToc: 84360, sumEfflSs: 153111, sumEfflTp: 8443, sumEfflTn: 35571, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '', yr: '2020', sumInflBod: 55280, sumInflToc: 35027, sumInflSs: 55125, sumInflTp: 1695, sumInflTn: 15442, sumEfflBod: 65532, sumEfflToc: 36455, sumEfflSs: 65256, sumEfflTp: 2830, sumEfflTn: 18291, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '', yr: '2021', sumInflBod: 66809, sumInflToc: 33841, sumInflSs: 65743, sumInflTp: 2015, sumInflTn: 17674, sumEfflBod: 65532, sumEfflToc: 36455, sumEfflSs: 65256, sumEfflTp: 2830, sumEfflTn: 18291, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
{ rvbsn: '', yr: '2022', sumInflBod: 74505, sumInflToc: 40498, sumInflSs: 74899, sumInflTp: 4780, sumInflTn: 21756, sumEfflBod: 65532, sumEfflToc: 36455, sumEfflSs: 65256, sumEfflTp: 2830, sumEfflTn: 18291, avgInflBod: 0, avgInflToc: 0, avgInflSs: 0, avgInflTp: 0, avgInflTn: 0, avgEfflBod: 0, avgEfflToc: 0, avgEfflSs: 0, avgEfflTp: 0, avgEfflTn: 0 },
];
export const defaultInitOperWqltState: OperWqltState = {
operWqltStatus: initialWqltData,
};
export const createOperWqltStore = (initState: OperWqltState = defaultInitOperWqltState) =>
createStore<OperWqltStore>()((set) => ({
...initState,
setOperWqltStatus: (operWqltStatus) => set(() => ({ operWqltStatus })),
}));
export const operWqltStore = createOperWqltStore();
// 물재이용 현황
export type ReuseState = {
reuseStatus: ReuseStatusContent[] | null;
};
export type ReuseStateActions = {
setReuseStatus: (reuseStatus: ReuseStatusContent[] | null) => void;
};
export type ReuseStore = ReuseState & ReuseStateActions;
const initialReuseStatusData: ReuseStatusContent[] = [
{ yr: '2021', sewage: 7335803, reclaimed: 254805510.3, rainwater: 14049224.5 },
{ yr: '2022', sewage: 7378907, reclaimed: 243457788.8, rainwater: 15128093.4 },
{ yr: '2023', sewage: 7537151, reclaimed: 222475997, rainwater: 13944732.5 },
{ yr: '2024', sewage: 7479136, reclaimed: 265625917.7, rainwater: 13305309.5 },
];
export const defaultInitReuseState: ReuseState = {
reuseStatus: initialReuseStatusData,
};
export const createReuseStore = (initState: ReuseState = defaultInitReuseState) =>
createStore<ReuseStore>()((set) => ({
...initState,
setReuseStatus: (reuseStatus) => set(() => ({ reuseStatus })),
}));
export const reuseStore = createReuseStore();
/**
* 두 객체 또는 배열의 깊은 비교를 수행합니다.
* @param obj1 비교할 첫 번째 객체
* @param obj2 비교할 두 번째 객체
* @returns 두 객체의 내용이 같으면 true, 그렇지 않으면 false
*/
// function deepEqual(obj1: any, obj2: any): boolean {
// if (obj1 === obj2) {
// return true;
// }
// if (obj1 == null || typeof obj1 !== 'object' || obj2 == null || typeof obj2 !== 'object') {
// return false;
// }
// const keys1 = Object.keys(obj1);
// const keys2 = Object.keys(obj2);
// if (keys1.length !== keys2.length) {
// return false;
// }
// for (const key of keys1) {
// if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) {
// return false;
// }
// }
// // 배열인 경우 순서와 내용이 모두 동일한지 확인
// if (Array.isArray(obj1) && Array.isArray(obj2)) {
// if (obj1.length !== obj2.length) return false;
// for (let i = 0; i < obj1.length; i++) {
// if (!deepEqual(obj1[i], obj2[i])) return false;
// }
// }
// return true;
// }
export class OperationApiService implements OperationService {
// 기준년도 조회
public async getStandardYear(): Promise<string> {
const { year, setYear } = yearStore.getState();
if (year) {
return year;
}
const response = await client.get<{
status: number;
data: string;
count: number;
}>(ENDPOINT.OPERATION_SERVICE.MAIN_YEAR);
const newYear = response.data.data || '2024';
setYear(newYear);
return newYear;
}
// 수계별 시설운영 개소수 및 가동률
public async getDashOperCntRt(year: string): Promise<OperFcltContent[]> {
const { operFcltStatus, setOperFcltStatus } = operFcltStore.getState();
const { setMapOperFclt } = useMapOperFcltStore.getState();
// 간단한 캐싱: 데이터가 이미 있으면 API 호출 생략
if (operFcltStatus && operFcltStatus.length > 0) {
return operFcltStatus;
}
const response = await client.get<{
status: number;
data: OperFcltContent[];
count: number;
}>(ENDPOINT.OPERATION_SERVICE.OPER_CNTRT, {
params: {
year,
},
});
const list = (response.data.data ?? []).map((item) => ({
...item,
cnt: Number(item.cnt),
fctCpc: Number(item.fctCpc),
}));
setOperFcltStatus(list);
setMapOperFclt(transformToMapData(list));
return list;
}
// 수계별 유입ㆍ방류수질 분석
public async getDashOperWqlt(year: string): Promise<OperWqltContent[]> {
const response = await client.get<{
status: number;
data: OperWqltContent[];
count: number;
}>(ENDPOINT.OPERATION_SERVICE.OPER_WQLT, {
params: {
year,
},
});
const list = (response.data.data ?? []).map((item) => ({
...item,
}));
return list;
}
// 물재이용 현황
public async getReuseStatus(): Promise<ReuseStatusContent[]> {
// API 호출 시에는 항상 최신 데이터를 가져오도록 캐싱 로직을 제거하거나 수정할 수 있습니다.
// 여기서는 기존 캐싱 로직을 유지하되, 초기 데이터가 아닌 경우에만 API를 호출하도록 합니다.
const { reuseStatus, setReuseStatus } = reuseStore.getState();
if (reuseStatus && reuseStatus.length > 0 && reuseStatus !== defaultInitReuseState.reuseStatus) {
return reuseStatus;
}
const response = await client.get<{
status: number;
data: ReuseStatusContent[];
count: number;
}>(ENDPOINT.OPERATION_SERVICE.REUSE_STATUS);
const list = (response.data.data ?? []).map((item) => ({
...item,
reclaimed: Number(item.reclaimed),
rainwater: Number(item.rainwater),
}));
setReuseStatus(list);
return list;
}
// 수계별 하수재이용량, 시설수
public async getReuseMap(year: string): Promise<ReuseMapContent[]> {
const response = await client.get<{
status: number;
data: ReuseMapContent[];
count: number;
}>(ENDPOINT.OPERATION_SERVICE.REUSE_MAP, {
params: {
year,
},
});
const list = (response.data.data ?? []).map((item) => ({
...item,
}));
return list;
}
}
export const OperationApi = new OperationApiService();
@@ -1,66 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageableList } from "@/shared/model";
export interface ScheduleParams {
siteId?: string;
schdlTtl?: string;
schdlSeCd?: string;
schdlBgngYmd?: string;
schdlEndYmd?: string;
reptYn?: string;
commSchdlYn?: string;
}
export interface Schedule {
schdlSn: number;
siteId: string;
schdlTtl: string;
schdlSeCd: string;
schdlSeNm?: string;
schdlBgngDt: string;
schdlEndDt: string;
reptYn: string;
commSchdlYn: string;
schdlCn?: string;
actualDate?: string;
atflCd?: string | null;
}
export const getScheduleList = async (params?: ScheduleParams): Promise<PageableList<Schedule>> => {
const response = await client.get<Schedule[]>(
ENDPOINT.CMS_SERVICE.SCHEDULES,
{ params },
);
const data = response.data;
return {
content: data as Schedule[],
pageable: {
sort: {
sorted: false,
unsorted: true,
empty: true,
},
offset: 0,
pageNumber: 0,
pageSize: data.length as number,
paged: false,
unpaged: true,
},
sort: {
sorted: false,
unsorted: true,
empty: true,
},
totalPages: 1,
totalElements: data.length,
number: 0,
numberOfElements: data.length,
size: data.length,
first: true,
last: true,
empty: data.length === 0,
};
};
@@ -1,189 +0,0 @@
// apps/cm/src/pages/dashboard/api/sn-service.ts
import { client } from "@/shared/api";
import { create } from "zustand";
import { ENDPOINT } from "@/shared/config";
import type { PplLengthContent, SewerageService } from "../model/sn-interface";
import type { OldPplChartData, mapPplOldType, mapPplOldItem } from "../model/sn-interface";
export type SnState = {
snStatus: PplLengthContent[] | null;
};
export type SnStateActions = {
setSnStatus: (snStatus: PplLengthContent[] | null) => void;
};
export type SnStore = SnState & SnStateActions;
const initialUsePplChartData: OldPplChartData = {
labels: ["금강", "한강", "낙동강", "영산강"],
datasets: [
{
label: "기타",
data: [1966, 15480, 6957, 1937],
backgroundColor: "rgba(255, 178, 150, 0.9)"
},
{
label: "오수관로",
data: [8074, 15879, 16337, 5671],
backgroundColor: "rgba(180, 240, 190, 0.95)"
},
{
label: "우수관로",
data: [4838, 12893, 10492, 4281],
backgroundColor: "rgba(170, 210, 245, 0.95)"
}
]
};
const initialOldPplChartData: OldPplChartData = {
labels: ["금강", "한강", "낙동강", "영산강"],
datasets: [
{
label: "20-25년",
data: [1140, 3181, 3100, 1662],
backgroundColor: "#3B82F6",
borderRadius: 4
},
{
label: "25-30년",
data: [1391, 3615, 1814, 1411],
backgroundColor: "#EF4444",
borderRadius: 4
},
{
label: "30년이상",
data: [2014, 10303, 9831, 2000],
backgroundColor: "#F87171",
borderRadius: 4
}
]
};
type OldPplStoreState = {
chartData: OldPplChartData;
setChartData: (chartData: OldPplChartData) => void;
};
export const useOldPplStore = create<OldPplStoreState>((set) => ({
chartData: initialOldPplChartData,
setChartData: (chartData) => set(() => ({ chartData })),
}));
export const useUsePplStore = create<OldPplStoreState>((set) => ({
chartData: initialUsePplChartData,
setChartData: (chartData) => set(() => ({ chartData })),
}));
export const initailMapOldPplData: mapPplOldType = {
colTitle: { totalLen: "총길이", pplLen2025: "20~25년", pplLen2530: "25~30년", pplLen30: "30년 이상" }, //헤더 영역
data: [
{ name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30: 2014 },
{ name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30: 10303 },
{ name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30: 9831 },
{ name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30: 2000 },
]
};
type mapPplOldState = {
mapData: mapPplOldItem[];
setMapData: (mapData: mapPplOldItem[]) => void;
};
export const useMapOldPplStore = create<mapPplOldState>((set) => ({
mapData: initailMapOldPplData.data,
setMapData: (newMapData) => set({ mapData: newMapData }),
}));
/**
* OldPplChartData를 지도 팝업 데이터 형태인 mapPplOldItem[]으로 변환합니다.
* @param chartData 차트 데이터
* @returns 유역별 노후관로 현황 데이터
*/
export function transformChartDataToMapData(chartData: OldPplChartData): mapPplOldItem[] {
const mapData: mapPplOldItem[] = [];
const { labels, datasets } = chartData;
const pplLen2025Data = datasets.find(d => d.label === '20-25년')?.data || [];
const pplLen2530Data = datasets.find(d => d.label === '25-30년')?.data || [];
const pplLen30Data = datasets.find(d => d.label === '30년이상')?.data || [];
labels.forEach((label, index) => {
const pplLen2025 = pplLen2025Data[index] || 0;
const pplLen2530 = pplLen2530Data[index] || 0;
const pplLen30 = pplLen30Data[index] || 0;
const totalLen = pplLen2025 + pplLen2530 + pplLen30;
mapData.push({
name: label,
totalLen,
pplLen2025,
pplLen2530,
pplLen30,
});
});
return mapData;
}
export class SewerageApiService implements SewerageService {
// 기간별 관로 총길이
public async getPplLength(instlYmdFrom: string, instlYmdTo: string): Promise<PplLengthContent[]> {
const response = await client.get<{
PplLengthList: PplLengthContent[];
}>(ENDPOINT.SEWERAGE_SERVICE.PPL_LENGTH, {
params: {
instlYmdFrom,
instlYmdTo,
},
});
const list = (response.data.PplLengthList ?? []).map((item) => ({
...item,
sumPrlgVl: Number(item.sumPrlgVl),
}));
return list;
}
// 기간별 용도별 관로 총길이 : useCd - SBA004 오수관, SBA003 우수관
public async getPplUseLength(instlYmdFrom: string, instlYmdTo: string, useCd: string): Promise<PplLengthContent[]> {
const response = await client.get<{
PplLengthList: PplLengthContent[];
}>(ENDPOINT.SEWERAGE_SERVICE.PPL_USE_LENGTH, {
params: {
instlYmdFrom,
instlYmdTo,
useCd,
},
});
const list = (response.data.PplLengthList ?? []).map((item) => ({
...item,
sumPrlgVl: Number(item.sumPrlgVl),
}));
return list;
}
// 유역별 용도별 관로 길이(km) MV조회 : usgCd - SBA004 오수관, SBA003 우수관
public async getMvUsgPplLength(): Promise<PplLengthContent[]> {
const response = await client.get<{
PplLengthList: PplLengthContent[];
}>(ENDPOINT.SEWERAGE_SERVICE.MV_USG_PPL_LENGTH);
const list = (response.data.PplLengthList ?? []).map((item) => ({
...item,
sumPrlgVl: Number(item.sumPrlgVl),
}));
return list;
}
// 유역별 노후도별 관로 길이(km) MV조회 : yearGroup - 20년~25년미만,25년~30년미만,30년이상
public async getMvOldPplLength(): Promise<PplLengthContent[]> {
const response = await client.get<{
PplLengthList: PplLengthContent[];
}>(ENDPOINT.SEWERAGE_SERVICE.MV_OLD_PPL_LENGTH);
const list = (response.data.PplLengthList ?? []).map((item) => ({
...item,
sumPrlgVl: Number(item.sumPrlgVl),
}));
return list;
}
}
export const SewerageApi = new SewerageApiService();
@@ -1,53 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageableList } from "@/shared/model";
export interface Support {
total: number;
ongoing: number;
han: number;
youngsang: number;
geum: number;
nakdong: number;
completed: number;
type: string;
}
export const getSupportList = async (): Promise<PageableList<Support>> => {
const response = await client.get<Support[]>(
`${ENDPOINT.IC_SERVICE.GROUPING_STATISTICS}`,
);
const data = response.data;
console.log("getSupportList data:", data);
return {
content: data as Support[],
pageable: {
sort: {
sorted: false,
unsorted: true,
empty: true,
},
offset: 0,
pageNumber: 0,
pageSize: data.length as number,
paged: false,
unpaged: true,
},
sort: {
sorted: false,
unsorted: true,
empty: true,
},
totalPages: 1,
totalElements: data.length,
number: 0,
numberOfElements: data.length,
size: data.length,
first: true,
last: true,
empty: data.length === 0,
};
};
@@ -0,0 +1,425 @@
import React, { useEffect, useRef } from "react";
import styles from "./KoreaBasinMap.module.css"; // 추가
const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요
const MANUAL_LABEL_POS = {
han: { x: 0.52, y: 0.14 },
geum: { x: 0.28, y: 0.47 },
nakdong: { x: 0.64, y: 0.47 },
yeongsan: { x: 0.18, y: 0.77 },
};
const BASIN_DATA = {
han: {
name: "한강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
geum: {
name: "금강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
nakdong: {
name: "낙동강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
yeongsan: {
name: "영산강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
};
const BASIN_IDS = [
{ key: "han", labels: ["한강", "han"] },
{ key: "geum", labels: ["금강", "geum"] },
{ key: "nakdong", labels: ["낙동강", "nakdong"] },
{ key: "yeongsan", labels: ["영산강", "yeongsan"] },
];
function fmt(n: number) {
return new Intl.NumberFormat("ko-KR").format(n);
}
export function KoreaBasinMap() {
const stageRef = useRef<HTMLDivElement>(null);
const svgLayerRef = useRef<HTMLDivElement>(null);
const labelsLayerRef = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const badgeRef = useRef<HTMLDivElement>(null);
const rowsRef = useRef<HTMLTableSectionElement>(null);
const colTitleRef = useRef<HTMLTableCellElement>(null);
useEffect(() => {
let svg: SVGSVGElement | null = null;
let gs: SVGGElement[] = [];
let labels: HTMLDivElement[] = [];
console.log("Loading SVG from:", SVG_URL);
fetch(SVG_URL)
.then((r) => {
console.log("SVG fetch response:", r.status, r.statusText);
if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`);
return r.text();
})
.then((txt) => {
console.log("SVG content loaded, length:", txt.length);
if (!svgLayerRef.current) {
console.error("svgLayerRef.current is null");
return;
}
svgLayerRef.current.innerHTML = txt;
svg = svgLayerRef.current.querySelector("svg");
if (!svg) {
console.error("SVG element not found in loaded content");
return;
}
console.log("SVG element found:", svg);
// viewBox/크기 세팅
if (!svg.hasAttribute("viewBox")) {
const bb = svg.getBBox();
svg.setAttribute(
"viewBox",
[bb.x, bb.y, bb.width, bb.height].join(" "),
);
}
const vb = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number);
if (vb.length === 4 && vb[3]! > 0 && stageRef.current) {
stageRef.current!.style.aspectRatio = String(vb[2]! / vb[3]!);
}
svg!.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.style.width = "100%";
svg.style.height = "100%";
// 1) 지도 그룹 수집
gs = [];
console.log("Looking for basin groups in SVG...");
BASIN_IDS.forEach((item) => {
let g: SVGGElement | null = null;
for (const id of item.labels) {
g = svg!.getElementById(id) as SVGGElement | null;
if (g) {
console.log(`Found group for ${item.key} with id '${id}':`, g);
break;
}
}
if (!g) {
console.warn(`No group found for ${item.key} with labels:`, item.labels);
return;
}
g.classList.add("basin");
(g as SVGGElement & { dataset: { key: string; label: string } }).dataset.key = item.key;
(g as SVGGElement & { dataset: { key: string; label: string } }).dataset.label = item.labels[0] || '';
// 패스에 기본 스타일 적용
const paths = g.querySelectorAll('path');
paths.forEach((path) => {
path.style.fill = 'rgba(0, 150, 255, 0.2)';
path.style.stroke = '#0066cc';
path.style.strokeWidth = '1';
path.style.vectorEffect = 'non-scaling-stroke';
});
gs.push(g);
});
console.log(`Found ${gs.length} basin groups`);
// 각 유역 그룹의 패스 정보 확인
gs.forEach((g, index) => {
const paths = g.querySelectorAll('path');
console.log(`Group ${index} (${(g as SVGGElement & { dataset: { key: string } }).dataset.key}):`, {
element: g,
pathCount: paths.length,
paths: Array.from(paths).map(p => ({
className: p.className.baseVal,
d: p.getAttribute('d')?.substring(0, 50) + '...'
}))
});
});
// 2) 함수들 먼저 정의
const clear = () => {
gs.forEach((el) => el.classList.remove("active"));
if (popupRef.current) {
if (styles.show) {
popupRef.current.classList.remove(styles.show);
}
popupRef.current.style.opacity = "0";
popupRef.current.style.visibility = "hidden";
}
if (badgeRef.current) {
if (styles.show) {
badgeRef.current.classList.remove(styles.show);
}
badgeRef.current.style.opacity = "0";
badgeRef.current.style.visibility = "hidden";
}
labels.forEach((l) => l.classList.remove("active"));
};
function showFor(g: SVGGElement) {
console.log("showFor called for:", g);
const key = (g as SVGGElement & { dataset: { key: string; label: string } }).dataset.key;
const data = (BASIN_DATA as Record<string, { name: string; colTitle: string; rows: (string | number)[][] }>)[key] || {
name: (g as SVGGElement & { dataset: { label: string } }).dataset.label,
colTitle: "",
rows: [],
};
console.log("Data for popup:", data);
if (badgeRef.current) badgeRef.current.textContent = data.name;
if (colTitleRef.current)
colTitleRef.current.textContent = data.colTitle || "???";
if (rowsRef.current)
rowsRef.current.innerHTML = data.rows
.map(
(r: (string | number)[]) =>
`<tr><td>${r[0]}</td><td style="text-align:right">${fmt(Number(r[1]) || 0)}</td></tr>`,
)
.join("");
if (!stageRef.current) return;
const sRect = stageRef.current.getBoundingClientRect(),
r = g.getBoundingClientRect();
const cx = (r.left + r.right) / 2 - sRect.left;
const ty = r.top - sRect.top;
console.log("Popup position:", { cx, ty });
if (badgeRef.current) {
badgeRef.current.style.left = cx + "px";
badgeRef.current.style.top = ty + 24 + "px";
}
if (popupRef.current) {
popupRef.current.style.left = cx + "px";
popupRef.current.style.top = ty + 6 + "px";
popupRef.current.style.opacity = "1";
popupRef.current.style.visibility = "visible";
if (styles.show) {
popupRef.current.classList.add(styles.show);
}
console.log("Added show class to popup:", popupRef.current.classList.toString());
}
if (badgeRef.current && styles.show) {
badgeRef.current.classList.add(styles.show);
}
if (popupRef.current) {
const pRect = popupRef.current.getBoundingClientRect();
const x = Math.max(
12 + pRect.width / 2,
Math.min(cx, sRect.width - 12 - pRect.width / 2),
);
popupRef.current.style.left = x + "px";
if (badgeRef.current) badgeRef.current.style.left = x + "px";
}
}
function showForWithLabel(g: SVGGElement) {
console.log("showForWithLabel called for:", g);
showFor(g);
const idx = gs.indexOf(g);
labels.forEach((l) => l.classList.remove("active"));
if (idx >= 0) labels[idx]!.classList.add("active");
}
// 3) 라벨 DOM 생성
labels = [];
if (labelsLayerRef.current) {
labelsLayerRef.current.innerHTML = "";
gs.forEach((g) => {
const el = document.createElement("div");
el.className = styles.basinLabel || '';
el.textContent = (g as SVGGElement & { dataset: { label: string } }).dataset.label || '';
labelsLayerRef.current!.appendChild(el);
labels.push(el);
});
}
// 3) 라벨 위치
function positionLabels() {
if (!stageRef.current || !svg) return;
const sRect = stageRef.current.getBoundingClientRect();
const inset =
parseFloat(
getComputedStyle(stageRef.current).getPropertyValue(
"--map-inset",
) || "0",
) / 100;
const left = sRect.width * inset,
top = sRect.height * inset;
const w = sRect.width * (1 - inset * 2),
h = sRect.height * (1 - inset * 2);
const [vx, vy, vw, vh] = (
svg!.getAttribute("viewBox") || "0 0 100 100"
)
.split(/\s+/)
.map(Number);
const svgRect = svg.getBoundingClientRect();
const stageRect = stageRef.current.getBoundingClientRect();
const offsetX = svgRect.left - stageRect.left - left;
const offsetY = svgRect.top - stageRect.top - top;
gs.forEach((g, i) => {
const key = (g as SVGGElement & { dataset: { key: string } }).dataset.key;
const manual = (MANUAL_LABEL_POS as Record<string, { x?: number; y?: number; px?: number; py?: number }>)[key];
let px, py;
if (
manual &&
(("x" in manual && "y" in manual) ||
("px" in manual && "py" in manual))
) {
if ("px" in manual) {
px = left + (manual.px || 0) + offsetX;
py = top + (manual.py || 0) + offsetY;
} else {
px = left + w * (manual.x || 0) + offsetX;
py = top + h * (manual.y || 0) + offsetY;
}
} else {
const b = g.getBBox();
const cx = b.x + b.width / 2,
cy = b.y + b.height / 2;
px = ((cx - vx!) / vw!) * w + left + offsetX;
py = ((cy - vy!) / vh!) * h + top + offsetY;
}
labels[i]!.style.left = `${px}px`;
labels[i]!.style.top = `${py + 18}px`;
});
}
// 4) 상호작용 (함수들은 이미 위에서 정의됨)
gs.forEach((g) => {
g.addEventListener("mouseenter", () => {
clear();
g.classList.add("active");
showForWithLabel(g);
});
g.addEventListener("mousemove", () => {
if (g.classList.contains("active")) showForWithLabel(g);
});
g.addEventListener("mouseleave", clear);
g.addEventListener("click", (e) => {
e.stopPropagation();
clear();
g.classList.add("active");
showForWithLabel(g);
});
});
if (stageRef.current) {
stageRef.current.addEventListener("mouseleave", clear);
stageRef.current.addEventListener("click", () => clear());
}
// 5) 실행
positionLabels();
window.addEventListener("resize", positionLabels);
new window.ResizeObserver(positionLabels).observe(stageRef.current!);
})
.catch((error) => {
console.error("Error loading SVG:", error);
if (svgLayerRef.current)
svgLayerRef.current.innerHTML =
`<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #666; font-size: 14px;">지도를 불러올 수 없습니다. (${error.message})</div>`;
});
// cleanup
return () => {
window.removeEventListener("resize", () => {});
};
}, []);
// 스타일은 필요에 따라 tailwind, css 등으로 커스터마이즈하세요.
return (
<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>
);
}
@@ -1,502 +0,0 @@
"use client";
/**
* 유역 지도
*/
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useStore } from "zustand";
import styles from "./korea-basin-map.module.css"; // 추가
import { basinStore } from "../api/fo-service";
import { basinKeyMap,MANUAL_LABEL_POS } from "../model/map-data";
import { BASIN_DATA, BASIN_IDS } from "../model/map-data";
import { svcInfo } from "../model/map-data";
import type { mapOperFcltType, mapReuseMapType} from "../model/fo-interface";
import type { mapPplOldType } from "../model/sn-interface";
import { useMapOperFcltStore, OperationApi, useMapReuseMapStore } from "../api/fo-service";
import { useMapOldPplStore } from "../api/sn-service";
import {Button} from "@common/components";
const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요
function fmt(n: number) {
return new Intl.NumberFormat("ko-KR").format(n);
}
const mapOperFcltData: mapOperFcltType = {
colTitle: {cnt:"개소수",rt:"가동률"}, //헤더 영역
data: [
{name: "금강", cnt: 710, rt: 100,},
{name: "한강", cnt: 983, rt: 100,},
{name: "낙동강", cnt: 1032, rt: 100,},
{name: "영산강", cnt: 1261, rt: 100,},
]
};
const mapPplOldData: mapPplOldType = {
colTitle: {totalLen: "총길이",pplLen2025: "20~25년",pplLen2530: "25~30년",pplLen30 : "30년 이상" }, //헤더 영역
data: [
{name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30 : 2014},
{name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30 : 10303},
{name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30 : 9831},
{name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30 : 2000},
]
};
const mapReuseMapData: mapReuseMapType = {
colTitle: {reuseAmnt: "하수재이용량",cnt : "시설수" }, //헤더 영역
data: [
{name: "금강", reuseAmnt: 17099, cnt: 3181},
{name: "한강", reuseAmnt: 17099, cnt: 3181},
{name: "낙동강", reuseAmnt: 14745, cnt: 3100},
{name: "영산강", reuseAmnt: 5073, cnt: 1662},
]
};
export function KoreaBasinMap() {
const stageRef = useRef<HTMLDivElement>(null);
const svgLayerRef = useRef<HTMLDivElement>(null);
const labelsLayerRef = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const [services, setServices] = useState(svcInfo);
const [popupContent, setPopupContent] = useState<{ title: string; items: { header: string; data: string }[] }>({
title: '',
items: [],
});
const { selectedBasin, setSelectedBasin } = useStore(basinStore);
const selectedBasinKey = basinKeyMap[selectedBasin];
const {mapOperFclt} = useMapOperFcltStore();
const {mapData: mapPplOld} = useMapOldPplStore();
const {setMapData: setMapReuseMapData} = useMapReuseMapStore();
mapOperFcltData.data = mapOperFclt;
mapPplOldData.data = mapPplOld;
// mapReuseMapData.data = mapReuseMap;
let standardYear = '2024';
useEffect(() => {
const fetchData = async () => {
try {
standardYear = await OperationApi.getStandardYear();
const mapReuseMapRslt = await OperationApi.getReuseMap(standardYear);
setMapReuseMapData(mapReuseMapRslt);
mapReuseMapData.data = mapReuseMapRslt;
} catch (e) {
console.error("하수재이용량 map 데이터 조회 실패", e);
}
};
fetchData();
}, []);
// 유역정보
const showFor = useCallback((g: SVGGElement) => {
const activeService = services.find(s => s.active);
if (!activeService) return;
const key = (g as SVGGElement & { dataset: { key: string; label: string } }).dataset.key;
const basinName = BASIN_DATA[key as keyof typeof BASIN_DATA]?.name || (g as SVGGElement & { dataset: { label: string } }).dataset.label;
const newPopupContent: { title: string; items: { header: string; data: string }[] } = { title: '', items: [] };
if (activeService.svcId === 'fo') { // 운영실태
const item = mapOperFcltData.data.find(d => d.name === basinName);
newPopupContent.title = `${standardYear}년도 ${basinName}유역 시설운영 현황`;
newPopupContent.items.push({
header: mapOperFcltData.colTitle.cnt,
data: item ? `${fmt(item.cnt)}개소` : '-',
});
newPopupContent.items.push({
header: mapOperFcltData.colTitle.rt,
data: item ? `${item.rt}%` : '-',
});
} else if (activeService.svcId === 'sn') { // 하수관로
const item = mapPplOldData.data.find(d => d.name === basinName);
newPopupContent.title = `${standardYear}년도 ${basinName}유역 노후하수관로 현황`;
const colTitles = mapPplOldData.colTitle;
newPopupContent.items.push({
header: colTitles.totalLen,
data: item ? `${fmt(item.totalLen)}km` : '-',
});
newPopupContent.items.push({
header: colTitles.pplLen2025,
data: item ? `${fmt(item.pplLen2025)}km` : '-',
});
newPopupContent.items.push({
header: colTitles.pplLen2530,
data: item ? `${fmt(item.pplLen2530)}km` : '-',
});
newPopupContent.items.push({
header: colTitles.pplLen30,
data: item ? `${fmt(item.pplLen30)}km` : '-',
});
} else if (activeService.svcId === 'wr') { // 물재이용
const item = mapReuseMapData.data.find(d => d.name === basinName);
newPopupContent.title = `${standardYear}년도 ${basinName}유역 물재이용 현황`;
newPopupContent.items.push({
header: mapReuseMapData.colTitle.reuseAmnt,
data: item ? `${fmt(item.reuseAmnt)}m³` : '-',
});
newPopupContent.items.push({
header: mapReuseMapData.colTitle.cnt,
data: item ? `${fmt(item.cnt)}개소` : '-',
});
}
setPopupContent(newPopupContent);
if (!stageRef.current) return;
const sRect = stageRef.current.getBoundingClientRect(),
r = g.getBoundingClientRect();
const cx = (r.left + r.right) / 2 - sRect.left;
const ty = r.top - sRect.top;
if (popupRef.current) {
popupRef.current.style.left = cx + "px";
popupRef.current.style.top = ty + 6 + "px";
popupRef.current.style.opacity = "1";
popupRef.current.style.visibility = "visible";
if (styles.show) {
popupRef.current.classList.add(styles.show);
}
}
if (popupRef.current) {
const pRect = popupRef.current.getBoundingClientRect();
const x = Math.max(
12 + pRect.width / 2,
Math.min(cx, sRect.width - 12 - pRect.width / 2),
);
popupRef.current.style.left = x + "px";
}
}, [services, mapOperFclt]);
// selectedBasinKey prop이 변경될 때 지도를 업데이트하는 useEffect
useEffect(() => {
if (selectedBasinKey) {
const svg = svgLayerRef.current?.querySelector("svg");
if (!svg) return;
// 모든 유역의 활성화 상태를 초기화합니다.
svg.querySelectorAll('.basin.active').forEach(el => el.classList.remove('active'));
const targetGroup = svg.querySelector(`.basin[data-key="${selectedBasinKey}"]`) as SVGGElement | null;
targetGroup?.classList.add('active');
}
}, [selectedBasinKey]);
useEffect(() => {
let svg: SVGSVGElement | null = null;
let gs: SVGGElement[] = [];
let labels: HTMLDivElement[] = [];
const clear = () => {
gs.forEach((el) => el.classList.remove("active"));
if (popupRef.current) {
if (styles.show) {
popupRef.current.classList.remove(styles.show);
}
popupRef.current.style.opacity = "0";
}
labels.forEach((l) => l.classList.remove("active"));
};
// const positionLabels = () => {
// // This function's content is moved inside the fetch callback
// };
// console.log("Loading SVG from:", SVG_URL);
fetch(SVG_URL)
.then((r) => {
// console.log("SVG fetch response:", r.status, r.statusText);
if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`);
return r.text();
})
.then((txt) => {
// console.log("SVG content loaded, length:", txt.length);
if (!svgLayerRef.current) {
console.error("svgLayerRef.current is null");
return;
}
svgLayerRef.current.innerHTML = txt;
svg = svgLayerRef.current.querySelector("svg");
if (!svg) {
console.error("SVG element not found in loaded content");
return;
}
// console.log("SVG element found:", svg);
// viewBox/크기 세팅
if (!svg.hasAttribute("viewBox")) {
const bb = svg.getBBox();
svg.setAttribute(
"viewBox",
[bb.x, bb.y, bb.width, bb.height].join(" "),
);
}
const vb = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number);
if (vb.length === 4 && vb[3]! > 0 && stageRef.current) {
stageRef.current!.style.aspectRatio = String(vb[2]! / vb[3]!);
}
svg!.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.style.width = "100%";
svg.style.height = "100%";
// 1) 지도 그룹 수집
gs = [];
// console.log("Looking for basin groups in SVG...");
BASIN_IDS.forEach((item) => {
let g: SVGGElement | null = null;
for (const id of item.labels) {
g = svg!.getElementById(id) as SVGGElement | null;
if (g) {
// console.log(`Found group for ${item.key} with id '${id}':`, g);
break;
}
}
if (!g) {
console.warn(`No group found for ${item.key} with labels:`, item.labels);
return;
}
g.classList.add("basin");
(g as SVGGElement & { dataset: { key: string; label: string } }).dataset.key = item.key;
(g as SVGGElement & { dataset: { key: string; label: string } }).dataset.label = item.labels[0] || '';
// 패스에 기본 스타일 적용
const paths = g.querySelectorAll('path');
paths.forEach((path) => {
path.style.fill = 'rgba(0, 150, 255, 0.2)';
path.style.stroke = '#0066cc';
path.style.strokeWidth = '1';
path.style.vectorEffect = 'non-scaling-stroke';
});
gs.push(g);
});
// console.log(`Found ${gs.length} basin groups`);
// 각 유역 그룹의 패스 정보 확인
// gs.forEach((g, index) => {
// const paths = g.querySelectorAll('path');
// // console.log(`Group ${index} (${(g as SVGGElement & { dataset: { key: string } }).dataset.key}):`, {
// // element: g,
// // pathCount: paths.length,
// // paths: Array.from(paths).map(p => ({
// // className: p.className.baseVal,
// // d: p.getAttribute('d')?.substring(0, 50) + '...'
// // }))
// // });
// });
function showForWithLabel(g: SVGGElement) {
// console.log("showForWithLabel called for:", g);
showFor(g);
const idx = gs.indexOf(g);
labels.forEach((l) => l.classList.remove("active"));
if (idx >= 0) labels[idx]!.classList.add("active");
}
// 3) 라벨 DOM 생성
labels = [];
if (labelsLayerRef.current) {
labelsLayerRef.current.innerHTML = "";
gs.forEach((g) => {
const el = document.createElement("div");
el.className = styles.basinLabel || '';
el.textContent = (g as SVGGElement & { dataset: { label: string } }).dataset.label || '';
labelsLayerRef.current!.appendChild(el);
labels.push(el);
});
}
// 3) 라벨 위치
const positionLabels = () => {
if (!stageRef.current || !svg) return;
const sRect = stageRef.current.getBoundingClientRect();
const inset =
parseFloat(
getComputedStyle(stageRef.current).getPropertyValue(
"--map-inset",
) || "0",
) / 100;
const left = sRect.width * inset,
top = sRect.height * inset;
const w = sRect.width * (1 - inset * 2),
h = sRect.height * (1 - inset * 2);
const [vx, vy, vw, vh] = (
svg!.getAttribute("viewBox") || "0 0 100 100"
)
.split(/\s+/)
.map(Number);
const svgRect = svg.getBoundingClientRect();
const stageRect = stageRef.current.getBoundingClientRect();
const offsetX = svgRect.left - stageRect.left - left;
const offsetY = svgRect.top - stageRect.top - top;
gs.forEach((g, i) => {
const key = (g as SVGGElement & { dataset: { key: string } }).dataset.key;
const manual = (MANUAL_LABEL_POS as Record<string, { x?: number; y?: number; px?: number; py?: number }>)[key];
let px, py;
if (
manual &&
(("x" in manual && "y" in manual) ||
("px" in manual && "py" in manual))
) {
if ("px" in manual) {
px = left + (manual.px || 0) + offsetX;
py = top + (manual.py || 0) + offsetY;
} else {
px = left + w * (manual.x || 0) + offsetX;
py = top + h * (manual.y || 0) + offsetY;
}
} else {
const b = g.getBBox();
const cx = b.x + b.width / 2,
cy = b.y + b.height / 2;
px = ((cx - vx!) / vw!) * w + left + offsetX;
py = ((cy - vy!) / vh!) * h + top + offsetY;
}
labels[i]!.style.left = `${px}px`;
labels[i]!.style.top = `${py + 18}px`;
});
}
// 4) 상호작용 (함수들은 이미 위에서 정의됨)
gs.forEach((g) => {
g.addEventListener("mouseenter", () => {
clear();
g.classList.add("active");
showForWithLabel(g);
});
g.addEventListener("mousemove", () => {
if (g.classList.contains("active")) showForWithLabel(g);
});
g.addEventListener("mouseleave", clear);
g.addEventListener("click", (e) => {
e.stopPropagation();
clear();
g.classList.add("active");
showForWithLabel(g);
const basinKey = g.dataset.key as keyof typeof BASIN_DATA | undefined;
if (basinKey) {
const basinName = BASIN_DATA[basinKey].name;
setSelectedBasin(basinName);
}
});
});
if (stageRef.current) {
stageRef.current.addEventListener("mouseleave", clear);
stageRef.current.addEventListener("click", () => clear());
}
// 5) 실행
positionLabels();
window.addEventListener("resize", positionLabels);
new window.ResizeObserver(positionLabels).observe(stageRef.current!);
})
.catch((error) => {
console.error("Error loading SVG:", error);
if (svgLayerRef.current)
svgLayerRef.current.innerHTML =
`<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #666; font-size: 14px;">지도를 불러올 수 없습니다. (${error.message})</div>`;
});
// cleanup
return () => {
window.removeEventListener("resize", () => {});
};
}, [selectedBasinKey, showFor]); // 의존성 배열에 mapOperFclt 추가 불필요
const handleSvcInfoClick = (clickedSvcId: string) => {
setServices(prevServices =>
prevServices.map(svc => ({
...svc,
active: svc.svcId === clickedSvcId,
}))
);
};
// 스타일은 필요에 따라 tailwind, css 등으로 커스터마이즈하세요.
return (
<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 className="flex gap-2">
{services.map((svc) => (
<Button
key={svc.name}
className="h-[3.2rem] float-right"
color={svc.active ? "primary" : "white"}
onClick={() => handleSvcInfoClick(svc.svcId)}
>
{svc.name}
</Button>
))}
</div>
</div>
<div id="mapArea" style={{height: "calc(100% - 40px)"}}>
<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={popupRef}
id="popup"
className={`${styles.popup} absolute z-20 font-sans border border-blue-200 rounded-lg shadow-md overflow-hidden opacity-0 invisible bg-blue-50`}
style={{
minWidth: 160,
display: 'grid',
gridTemplateColumns: `repeat(${Math.max(1, popupContent.items.length)}, 1fr)`,
}}
>
{/* 제목 영역 */}
<div className="bg-blue-100 text-center font-bold border-b-2 border-blue-200 flex items-center justify-center" style={{ gridColumn: `span ${Math.max(1, popupContent.items.length)}` }}>
{popupContent.title}
</div>
{/* 헤더 및 데이터 동적 렌더링 */}
{popupContent.items.map((item, index) => (
<div key={`header-${index}`} className={`text-center border-b-2 border-blue-200 flex items-center justify-center ${index < popupContent.items.length - 1 ? 'border-r-2' : ''}`}>
{item.header}
</div>
))}
{popupContent.items.map((item, index) => (
<div key={`data-${index}`} className={`text-center flex items-center justify-center ${index < popupContent.items.length - 1 ? 'border-r-2' : ''}`}>
{item.data}
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
export interface SiteStatisticsContent {
siteId: string;
siteNm: string;
accessHour: string;
accessCnt: number;
}
export interface MenuStatisticsContent {
menuCd: number;
menuNm: string;
accessCnt: number;
}
export interface Dashboard {
memberTotCnt: number;
memberTodayCnt: number;
pstTotCnt: number;
pstTodayCnt: number;
accessTodayCnt: number;
accessBefDayPer: number;
apiTodayCnt: number;
waitMemberCnt: number;
waitCntnCnt: number;
notiList: [];
schdlList: [];
siteAccessList: SiteStatisticsContent[];
menuAccessList: MenuStatisticsContent[];
}
export interface DashboardService {
getDashboard(): Promise<Dashboard>;
}
@@ -1,77 +0,0 @@
export interface OperFcltContent {
rvbsn: string; // 유역
cnt: number; // 개소수
fctCpc: number; // 가동률 (용량)
gubun: string; // 구분
}
export interface OperWqltContent {
rvbsn: string; // 유역명
yr: string; // 연도
sumInflBod: number; // 유입수 BOD 합계
sumInflToc: number; // 유입수 TOC 합계
sumInflSs: number; // 유입수 SS 합계
sumInflTp: number; // 유입수 TP 합계
sumInflTn: number; // 유입수 TN 합계
sumEfflBod: number; // 유출수 BOD 합계
sumEfflToc: number; // 유출수 TOC 합계
sumEfflSs: number; // 유출수 SS 합계
sumEfflTp: number; // 유출수 TP 합계
sumEfflTn: number; // 유출수 TN 합계
avgInflBod: number; // 기간 평균 유입수 BOD
avgInflToc: number; // 기간 평균 유입수 TOC
avgInflSs: number; // 기간 평균 유입수 SS
avgInflTp: number; // 기간 평균 유입수 TP
avgInflTn: number; // 기간 평균 유입수 TN
avgEfflBod: number; // 기간 평균 유출수 BOD
avgEfflToc: number; // 기간 평균 유출수 TOC
avgEfflSs: number; // 기간 평균 유출수 SS
avgEfflTp: number; // 기간 평균 유출수 TP
avgEfflTn: number; // 기간 평균 유출수 TN
}
export interface ReuseMapContent {
name: string; // 유역명
reuseAmnt: number; // 하수재이용량
cnt: number; // 시설수
}
export interface ReuseStatusContent {
yr: string;
sewage: number;
reclaimed: number;
rainwater: number;
}
export interface OperationService {
getStandardYear(): Promise<string> ;
getDashOperCntRt(year: string): Promise<OperFcltContent[]>;
getDashOperWqlt(year: string): Promise<OperWqltContent[]>;
getReuseStatus(): Promise<ReuseStatusContent[]>;
}
export type mapOperFcltItem = {
name: string;
cnt: number;
rt: number;
};
export type mapOperFcltType = {
colTitle: {
cnt: string; // 개소수
rt: string; // 가동률
};
data: mapOperFcltItem[];
};
export type mapReuseMapType = {
colTitle: {
reuseAmnt: string; // 하수재이용량
cnt: string; // 시설수
};
data: ReuseMapContent[];
};
@@ -1,65 +0,0 @@
export const basinKeyMap: { [key: string]: string } = {
"한강": "han",
"금강": "geum",
"낙동강": "nakdong",
"영산강": "yeongsan",
};
// --- 라벨 좌표(수동 고정값) : inset 적용 영역 기준 퍼센트 또는 px 사용 ---
// - 퍼센트: { x:0~1, y:0~1 } (권장: 반응형에 유리)
// - 픽셀: { px:number, py:number } (inset 좌상단 원점 기준)
export const MANUAL_LABEL_POS = {
han: { x: 0.52, y: 0.14 },
geum: { x: 0.28, y: 0.47 },
nakdong: { x: 0.64, y: 0.47 },
yeongsan: { x: 0.18, y: 0.77 },
};
export const BASIN_DATA = {
han: {
name: "한강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
geum: {
name: "금강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
nakdong: {
name: "낙동강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
yeongsan: {
name: "영산강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
};
export const BASIN_IDS = [
{ key: "han", labels: ["한강", "han"] },
{ key: "geum", labels: ["금강", "geum"] },
{ key: "nakdong", labels: ["낙동강", "nakdong"] },
{ key: "yeongsan", labels: ["영산강", "yeongsan"] },
];
export const svcInfo = [
{svcId: "wr", active: true, name:"물재이용"},
{svcId: "sn", active: false, name:"하수관로"},
{svcId: "fo", active: false, name:"운영실태"},
]
@@ -1,85 +0,0 @@
import { basinKeyMap } from "../model/map-data";
export interface PplLengthContent {
zoneNm: string;
sumPrlgVl: number;
usgCd?: string;
yearGroup?: string;
}
export interface SewerageService {
getPplLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
getPplUseLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
getMvUsgPplLength(): Promise<PplLengthContent[]>;
getMvOldPplLength(): Promise<PplLengthContent[]>;
}
export interface ChartDataSet {
label: string;
data: (number | undefined)[];
backgroundColor: string;
borderRadius?: number;
}
export interface OldPplChartData {
labels: string[];
datasets: ChartDataSet[];
}
type SnOldPplItem = {
name: string;
colTitle: string[];
rows: number[];
};
type SnOldPplLength = {
[key: string]: SnOldPplItem;
};
export type mapPplOldItem = {
name: string;
totalLen: number;
pplLen2025: number;
pplLen2530: number;
pplLen30: number;
};
export type mapPplOldType = {
colTitle: {
totalLen: string; // 총길이
pplLen2025: string; // 20~25년
pplLen2530: string; // 25~30년
pplLen30: string; // 30년 이상
};
data: mapPplOldItem[];
};
export function buildSnOldPplLength(
chartData: {
labels: string[];
datasets: { label: string; data: number[] }[];
},
totalLengths: number[]
): SnOldPplLength {
const colTitle = ["총길이", ...chartData.datasets.map((ds) => ds.label)];
const result: SnOldPplLength = {};
chartData.labels.forEach((basinName, index) => {
const key = basinKeyMap[basinName]; // geum, han, nakdong, yeongsan
if (!key) return; // 매핑 안되면 스킵
const total = totalLengths[index] ?? 0;
const datasetValues = chartData.datasets.map(
(ds) => ds.data[index] ?? 0,
);
result[key] = {
name: basinName,
colTitle,
rows: [
total, // 총길이 (undefined면 0)
...datasetValues, // 각 연차 데이터 (undefined면 0)
],
};
});
return result;
}
@@ -1,49 +0,0 @@
import type { ColumnDef } from "@tanstack/react-table";
import type { Support } from "../api/support-service";
const typeLabelMap: Record<string, string> = {
waterQuality: "수질",
flood: "침수",
technical: "기술지원",
};
const supportTableColumns: ColumnDef<Support>[] = [
{
accessorKey: "type",
header: "구분",
cell: ({ getValue }) => {
const value = getValue() as string;
return typeLabelMap[value] || value;
},
},
{
accessorKey: "total",
header: "합계",
},
{
accessorKey: "han",
header: "한강",
},
{
accessorKey: "nakdong",
header: "낙동강",
},
{
accessorKey: "geum",
header: "금강",
},
{
accessorKey: "youngsang",
header: "영산강",
},
{
accessorKey: "ongoing",
header: "진행중",
},
{
accessorKey: "completed",
header: "완료",
},
];
export { supportTableColumns };
@@ -1,9 +1,28 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import type { MyPost, NoticePost } from "@/entities/post";
import type { Schedule } from "../api/schedule-service";
import { Badge } from "@common/components";
import type { Post, MyPost } from "@/entities/post";
const noticePostTableColumns: ColumnDef<NoticePost>[] = [
const noticePostTableColumns: ColumnDef<Post>[] = [
{
header: "번호",
cell: ({ row, table }) => {
if (row.original.upndFixYn === "Y") {
return (
<Badge
color="orange"
className="flex size-8 items-center justify-center"
>
<i className="diveicon di-volume-up-fill" />
</Badge>
);
}
const totalRows = table.getRowCount();
const currentPage = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return totalRows - (currentPage * pageSize + row.index);
},
},
{
accessorKey: "pstTtl",
header: "제목",
@@ -24,35 +43,33 @@ const noticePostTableColumns: ColumnDef<NoticePost>[] = [
];
const myPostTableColumns: ColumnDef<MyPost>[] = [
{
header: "번호",
cell: ({ row, table }) => {
const totalRows = table.getRowCount();
const currentPage = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return totalRows - (currentPage * pageSize + row.index);
},
},
{
accessorKey: "pstTtl",
header: "제목",
},
{
accessorKey: "wrtrNm",
header: "작성자",
},
{
accessorKey: "regDt",
header: "작성일",
cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"),
},
];
const scheduleTableColumns: ColumnDef<Schedule>[] = [
{
accessorKey: "schdlTtl",
header: "주요일정",
cell: ({ row }) => {
const startDate = dayjs(row.original.schdlBgngDt).format("YYYY-MM-DD");
const endDate = dayjs(row.original.schdlEndDt).format("YYYY-MM-DD");
const schdlTtl = row.original.schdlTtl;
return (
<div className="text-left">
[{startDate} ~ {endDate}]<br />{schdlTtl}
</div>
);
},
accessorKey: "inqNocs",
header: "조회수",
},
];
export { noticePostTableColumns, myPostTableColumns, scheduleTableColumns };
export { noticePostTableColumns, myPostTableColumns };
@@ -1,186 +0,0 @@
"use client";
import { useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { getImageUrlByAtflCd, getBannerList, type Banner } from "../api/banner-service";
import { getCookie } from "@/shared/lib";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
Autoplay,
} from "@common/components";
type BannerImageProps = {
atflCd: string;
lnkgUrlAddr: string;
};
const BannerList: React.FC = () => {
const siteId = getCookie("site-id-key") ?? "";
const BannerImage = ({ atflCd, 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-[210px] w-full items-center justify-center bg-accent">
<div className="text-sm text-muted-foreground">로딩 중...</div>
</div>
);
}
if (!imageUrl) {
return (
<div className="relative flex h-[210px] w-full items-center justify-center bg-accent">
<div className="text-sm text-muted-foreground">이미지 없음</div>
</div>
);
}
return (
<div
className="relative h-[210px] w-full cursor-pointer overflow-hidden transition-opacity hover:opacity-80"
onClick={handleClick}
>
<div
className="h-full w-full bg-cover bg-center"
style={{ backgroundImage: `url(${imageUrl})` }}
role="img"
aria-label="배너 이미지"
/>
</div>
);
};
const {
data: bannerList,
error: bannerError,
isLoading: isLoadingBanners,
} = useQuery<Banner[]>({
queryKey: [ENDPOINT.CMS_SERVICE.BANNERS, siteId],
queryFn: () => getBannerList(siteId),
enabled: !!siteId,
retry: false,
});
const plugin = useRef(Autoplay({ delay: 2000, stopOnInteraction: true }));
// MAIN 타입 배너만 필터링
const mainBanners =
bannerList?.filter((banner) => banner.bnrTypeCd === "BNRMAIN") ?? [];
// 에러 상태 판별 헬퍼 함수
const getErrorStatus = (e: unknown): number | undefined => {
if (!e || typeof e !== "object") return undefined;
const maybe = e as { status?: number; response?: { status?: number } };
return maybe.status ?? maybe.response?.status;
};
// 401 에러 처리
if (bannerError) {
const is401Error =
(bannerError instanceof Error && bannerError.message.includes("401")) ||
getErrorStatus(bannerError) === 401;
if (is401Error) {
console.error("[BannerList] 401 인증 오류:", bannerError);
return (
<div className="card card-border">
<div className="relative flex h-[280px] w-full items-center justify-center bg-accent">
<div className="text-sm text-red-500">
인증이 필요합니다. 로그인해주세요.
</div>
</div>
</div>
);
}
console.error("[BannerList] 배너 조회 오류:", bannerError);
return (
<div className="card card-border">
<div className="relative flex h-[240px] w-full items-center justify-center bg-accent">
<div className="text-sm text-muted-foreground">
배너를 불러올 수 없습니다
</div>
</div>
</div>
);
}
// 로딩 중
if (isLoadingBanners) {
return (
<div className="card card-border">
<div className="relative flex h-[240px] w-full items-center justify-center bg-accent">
<div className="text-sm text-muted-foreground">배너 로딩 중...</div>
</div>
</div>
);
}
// siteId가 없을 때
if (!siteId) {
return (
<div className="card card-border">
<div className="relative flex h-[240px] w-full items-center justify-center bg-accent">
<div className="text-sm text-muted-foreground">
사이트 ID가 없습니다
</div>
</div>
</div>
);
}
return (
<div className="card card-border" style={{ height: "230px" }}>
<Carousel
plugins={[plugin.current]}
opts={{ loop: true }}
className="relative"
>
<CarouselContent className="-ml-0">
{mainBanners.length > 0 ? (
mainBanners.map((banner) => (
<CarouselItem
key={`banner-${banner.bnrSn}`}
className="basis-full pl-0"
>
<BannerImage
atflCd={banner.atflCd}
lnkgUrlAddr={banner.lnkgUrlAddr}
/>
</CarouselItem>
))
) : (
<CarouselItem className="basis-full pl-0">
<div className="relative flex h-[210px] 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 z-10 !bg-[rgba(255,255,255,0.8)] text-accent-foreground" />
<CarouselNext className="absolute right-2 top-1/2 z-10 !bg-[rgba(255,255,255,0.8)] text-accent-foreground" />
</Carousel>
</div>
);
};
export default BannerList;
@@ -1,42 +0,0 @@
"use client";
import React from "react";
import {
cn,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
interface BasinSelectProps {
selectedBasin: string;
onValueChange: (value: string) => void;
className?: string;
}
const BasinSelect: React.FC<BasinSelectProps> = ({
selectedBasin,
onValueChange,
className
}) => {
const basins = ["한강", "금강", "낙동강", "영산강"];
return (
<Select value={selectedBasin} onValueChange={onValueChange}>
<SelectTrigger className={cn("w-[12rem]", className)}>
<SelectValue placeholder="유역 선택" />
</SelectTrigger>
<SelectContent>
{basins.map((basin) => (
<SelectItem key={basin} value={basin}>
{basin}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
export default BasinSelect;
+163 -73
View File
@@ -1,11 +1,10 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import { KoreaBasinMap } from "../lib/korea-basin-map";
import { KoreaBasinMap } from "../lib/KoreaBasinMap";
import {
Chart as ChartJS,
CategoryScale,
@@ -18,18 +17,7 @@ import {
Tooltip,
ArcElement,
} from "chart.js";
import SnPplStatus from "./sn-ppl-status";
import SnOldPpl from "./sn-old-ppl";
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";
import SupportList from "./support-list";
import DataStatusList from "./datastatus-list";
import MyPostList from "./mypost-list";
import ScheduleList from "./schedule-list";
import { Doughnut } from "react-chartjs-2";
// Chart.js 등록
ChartJS.register(
@@ -54,7 +42,8 @@ interface UserInfo {
function DashboardPage() {
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [error, setError] = useState("");
const router = useRouter();
const router = useRouter();
useEffect(() => {
const fetchUserInfo = async () => {
console.log("fetchUserInfo called");
@@ -113,96 +102,197 @@ function DashboardPage() {
};
return (
<div className="p-2 sm:p-4 md:p-6">
<div className="p-6">
<div className="flex flex-col gap-1">
{/* 첫번째 라인 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-8 gap-1">
<div className="grid grid-cols-8 gap-1">
{/* 첫번쨰 열 */}
<div className="col-span-1 md:col-span-1 lg:col-span-1 xl:col-span-2 h-250 !p-0">
{/* 전국 하수처리시설 개소수 및 가동률 */}
<FoFcltCntRt />
<div className="col-span-2 h-250 !p-0">
{/* 전국 하수처리시설 가동률 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">전국 하수처리시설 가동률</h3>
</div>
<div className="h-60 p-4">
<Doughnut
data={{
datasets: [{
data: [75, 25], // 75% 가동률
backgroundColor: ['#00DFE5', '#f5f5f5'],
borderWidth: 0
}]
}}
options={{
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
circumference: 180, // 반원
rotation: -90, // 왼쪽에서 시작
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
}
}}
plugins={[{
id: 'centerText',
afterDraw: (chart) => {
const ctx = chart.ctx;
const centerX = chart.width / 2;
const centerY = chart.height / 2;
// 가동률 텍스트
ctx.save();
ctx.font = 'bold 24px Arial';
ctx.fillStyle = '#00DFE5';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('75%', centerX, centerY + 10);
// 라벨 텍스트
ctx.font = '12px Arial';
ctx.fillStyle = '#666';
ctx.fillText('가동률', centerX, centerY + 30);
ctx.restore();
}
}]}
/>
</div>
</div>
{/* 오수관로 및 우수관로 현황 */}
<SnPplStatus />
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">오수관로 및 우수관로 현황</h3>
</div>
<div className="h-60 p-4"></div>
</div>
{/* 하수처리수 재이용 (m³) */}
<ReuseChart />
{/* 하수처리수 재이용 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">하수처리수 재이용</h3>
</div>
<div className="h-60 p-4"></div>
</div>
</div>
{/* 두번쨰 열 */}
<div className="col-span-1 md:col-span-1 lg:col-span-1 xl:col-span-2 h-250 !p-0">
{/* 수계별 유입ㆍ방류수질 분석 */}
<FoWqltInflEffl />
<div className="col-span-2 h-250 !p-0">
{/* 하수처리시설(개소) */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">하수처리시설(개소)</h3>
</div>
<div className="grid grid-cols-2 gap-1 h-60 !p-0">
<div className="card card-border">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium" style={{textAlign: "center", width: "100%"}}>500㎥ 미만(개소)</h3>
</div>
<div style={{fontSize: "38px", textAlign: "center", fontWeight: "600", lineHeight: "1.2", color: "#0ec58a", padding: "2px 0"}}>3,567 <span style={{fontSize: "14px", color: "#000000"}}>개소</span></div>
</div>
<div className="card card-border">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium" style={{textAlign: "center", width: "100%"}}>500㎥ 이상(개소)</h3>
</div>
<div style={{fontSize: "38px", textAlign: "center", fontWeight: "600", lineHeight: "1.2", color: "#0ec58a", padding: "2px 0"}}>767 <span style={{fontSize: "14px", color: "#000000"}}>개소</span></div>
</div>
<div className="col-span-2" style={{width: "100%", textAlign: "right"}}>기준년월 : 2025.09 </div>
</div>
</div>
{/* 노후하수관로현황 */}
<SnOldPpl />
<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>
</div>
{/* 물재이용 현황 (재이용량, m³) */}
<ReuseStatus />
{/* 재이용시설(개소) */}
<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>
</div>
</div>
{/* 지도영역 */}
<div className="col-span-1 md:col-span-2 lg:col-span-1 xl:col-span-3">
<KoreaBasinMap />
<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>
<KoreaBasinMap />
</div>
</div>
{/* 사용자 정보 카드 */}
<div className="col-span-1 md:col-span-2 lg:col-span-1 xl:col-span-1 !p-0">
<div className="card card-border col-span-1 h-80 p-10">
<h2 className="mb-6 text-2xl">사용자 정보</h2>
{error && <div className="mb-4 text-red-500">{error}</div>}
{userInfo ? (
<div className="card card_border login_info_box" style={{ minHeight: "150px", height: "auto" }}>
<div className="login_header" style={{ paddingLeft: "10px", paddingRight: "10px" }}>
<div className="login_avatar">
<Image
src="/images/icon_loginID.png"
alt="프로필"
width={40}
height={40}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Ccircle cx='20' cy='20' r='18' fill='%23e1e5e9'/%3E%3Ctext x='20' y='26' font-size='20' text-anchor='middle' fill='%23999'%3E%3F%3C/text%3E%3C/svg%3E";
}}
/>
<div className="mb-4">
<div>
<b>아이디:</b> {userInfo.userId}
</div>
<div className="login_user_info">
<div className="user_name">{userInfo.userName || "-"}</div>
<div className="user_status">온라인</div>
<div className="user_status text-xs sm:text-sm">
<b>2FA 활성화:</b>{" "}
{userInfo.twoFactorEnabled ? "예" : "아니오"}
</div>
<div>
<b>이메일:</b> {userInfo.email || userInfo.userId}
</div>
<div>
<b>이름:</b> {userInfo.userName || "-"}
</div>
<div>
<b>2FA 활성화:</b>{" "}
{userInfo.twoFactorEnabled ? "예" : "아니오"}
</div>
</div>
<div className="login_actions" style={{ paddingLeft: "10px", paddingRight: "10px" }}>
<button className="login_btn text-xs sm:text-sm" onClick={handleLogout}>로그아웃</button>
<button className="signup_btn text-xs sm:text-sm" onClick={handleChangePassword}>마이페이지</button>
</div>
</div>
) : (
!error && <div>사용자 정보를 불러오는 중...</div>
)}
<ScheduleList />
<MyPostList />
</div>
<button className="btn mb-2 w-full" onClick={handleLogout}>
로그아웃
</button>
<button className="btn w-full" onClick={handleChangePassword}>
패스워드 변경
</button>
</div>
</div>
{/* 두번째 라인 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-1">
<div className="card card-border" style={{ height: "250px" }}>
<SupportList />
<div className="grid grid-cols-4 gap-1">
<div className="card card-border"id="grouping-statistics">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">지원현황</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
<div className="card card-border" style={{ height: "250px" }}>
<DataStatusList />
<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>
</div>
<div className="card card-border" style={{ height: "250px" }}>
<NoticeList />
<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>
</div>
{/* 배너 영역 */}
<div className="card card-border" style={{ height: "250px" }}>
<BannerList />
<div className="card card-border">
</div>
</div>
</div>
@@ -1,174 +0,0 @@
"use client";
import { useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { getDataStatusList, type DataStatus } from "../api/datastatus-service";
import type { PageableList } from "@/shared/model";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
Autoplay,
} from "@common/components";
// 지역 타입에 따른 아이콘과 클래스 결정
const getRegionInfo = (regionName: string) => {
if (regionName.includes("서울특별시")) {
return { icon: "🏛️", className: "special-city" };
}
if (
regionName.includes("광역시") ||
regionName.includes("부산") ||
regionName.includes("대구") ||
regionName.includes("인천") ||
regionName.includes("광주") ||
regionName.includes("대전") ||
regionName.includes("울산")
) {
return { icon: "🏭", className: "metropolitan-city" };
}
return { icon: "🌊", className: "province" };
};
const DataStatusList: React.FC = () => {
const plugin = useRef(
Autoplay({ delay: 3000, stopOnInteraction: true })
);
const {
data: dataStatusResponse,
error: dataStatusError,
isLoading: isLoadingDataStatus,
} = useQuery<PageableList<DataStatus>>({
queryKey: [ENDPOINT.DC_SERVICE.DATA_STATUS],
queryFn: () => getDataStatusList(),
enabled: true,
retry: false,
staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
refetchOnWindowFocus: false, // 윈도우 포커스 시 리페칭 방지
refetchOnMount: false, // 마운트 시 리페칭 방지 (캐시된 데이터 사용)
});
const dataStatusList = dataStatusResponse?.content || [];
// 에러 상태 판별 헬퍼 함수
const getErrorStatus = (e: unknown): number | undefined => {
if (!e || typeof e !== "object") return undefined;
const maybe = e as { status?: number; response?: { status?: number } };
return maybe.status ?? maybe.response?.status;
};
// 401 에러 처리
if (dataStatusError) {
const is401Error =
(dataStatusError instanceof Error && dataStatusError.message.includes("401")) ||
getErrorStatus(dataStatusError) === 401;
if (is401Error) {
console.error("[DataStatusList] 401 인증 오류:", dataStatusError);
return (
<div className="card card-border">
<div className="relative flex h-[280px] w-full items-center justify-center bg-accent">
<div className="text-sm text-red-500">
인증이 필요합니다. 로그인해주세요.
</div>
</div>
</div>
);
}
console.error("[DataStatusList] 데이터 조회 오류:", dataStatusError);
return (
<div className="card card-border">
<div className="relative flex h-[240px] w-full items-center justify-center bg-accent">
<div className="text-sm text-muted-foreground">
데이터를 불러올 수 없습니다
</div>
</div>
</div>
);
}
// 로딩 중
if (isLoadingDataStatus) {
return (
<div className="card card-border">
<div className="relative flex h-[200px] w-full items-center justify-center bg-accent" >
<div className="text-sm text-muted-foreground">데이터 로딩 중...</div>
</div>
</div>
);
}
return (
<>
<div className="card_title bottomline">
<div className="leftArea">
<h4>자료현황</h4>
</div>
</div>
<div className="card card_paddingnone">
<div className="mid_info_area" style={{ width: "100%", padding: "5px" }}>
<Carousel
plugins={[plugin.current]}
opts={{ loop: true }}
className="relative mi_list_swiper"
>
<CarouselContent className="-ml-4 mi_list">
{dataStatusList.length > 0 ? (
dataStatusList.map((dataStatus) => {
const regionInfo = getRegionInfo(dataStatus.ctpvnm);
return (
<CarouselItem
key={`dataStatus-${dataStatus.ctpvnm}`}
className={`basis-1/2 pl-4 ml_box ${regionInfo.className}`}
style={{ width: "200px" }}
>
<div className="mb_section">
<div className="tit">
<i className="cloud-arrow-down-fill color_3">
{regionInfo.icon}
</i>
<span className="tt">{dataStatus.ctpvnm}</span>
</div>
<div className="info_row">
<div className="info_item">
<div className="info_label">기본계획</div>
<div className="info_value">
{dataStatus.ctpvgnsvycnt}
</div>
</div>
<div className="info_item">
<div className="info_label">파일수</div>
<div className="info_value">{dataStatus.filecnt}</div>
</div>
</div>
</div>
</CarouselItem>
);
})
) : (
<CarouselItem className="basis-full pl-0" style={{ height: "210px" }}>
<div className="relative flex h-[280px] 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 z-10 !bg-[rgba(255,255,255,0.8)] text-accent-foreground" />
<CarouselNext className="absolute right-2 top-1/2 z-10 !bg-[rgba(255,255,255,0.8)] text-accent-foreground" />
</Carousel>
</div>
</div>
</>
);
};
export default DataStatusList;
@@ -1,149 +0,0 @@
"use client";
/**
* 수계별 시설운영 개소수 및 가동률
*/
import React, { useEffect, useState, useMemo } from 'react';
import { useStore } from "zustand";
import BasinSelect from "./basIn-select";
import { Doughnut } from "react-chartjs-2";
import type { Chart as ChartJS } from "chart.js";
// import type { mapOperFcltItem, mapBasinData } from "../model/fo-interface";
import { OperationApi, operFcltStore, basinStore } from "../api/fo-service";
const FoFcltCntRt: React.FC = () => {
const [year, setYear] = useState<string | null>(null);
const { selectedBasin, setSelectedBasin } = useStore(basinStore);
const operFcltStatus = useStore(operFcltStore, (state) => state.operFcltStatus);
const onBasinChange = (value: string) => {
setSelectedBasin(value);
};
useEffect(() => {
const fetchData = async () => {
try {
const standardYear = await OperationApi.getStandardYear();
setYear(standardYear);
await OperationApi.getDashOperCntRt(standardYear);
} catch (e) {
console.error("시설운영 데이터 조회 실패", e);
}
};
fetchData();
}, []);
const displayData = useMemo(() => {
if (!operFcltStatus) {
return { facilityCount: 0, operatingRate: 0, chartData: [0, 100] };
}
const basinData = operFcltStatus.filter(d => d.rvbsn === selectedBasin); // 선택된 유역의 데이터만 filter
const operatingData = basinData.find(d => d.gubun === 'R'); // 선택 유역의 R 데이터만
const nonOperatingData = basinData.find(d => d.gubun === 'A');
const facilityCount = operatingData?.cnt ?? 0;
const operatingCapacity = operatingData?.fctCpc ?? 0;
const nonOperatingCapacity = nonOperatingData?.fctCpc ?? 0;
const totalCapacity = operatingCapacity + nonOperatingCapacity;
const operatingRate = totalCapacity > 0 ? Math.round((operatingCapacity / totalCapacity) * 100) : 0;
const result = {
facilityCount,
operatingRate,
chartData: [operatingCapacity, nonOperatingCapacity],
};
// console.log(result);
return result;
}, [operFcltStatus, selectedBasin]);
return (
<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} />
</div>
<div className="h-70 p-4">
<div className="flex gap-2 h-full">
{/* 왼쪽: 개소수 카드 */}
<div className="flex-1">
<div className="card card-border h-full flex flex-col">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium mx-auto">개소수 ({year}년도)</h3>
</div>
<div className="flex items-center justify-center flex-1" style={{ height: "200px" }}>
<div style={{ fontSize: "38px", textAlign: "center", fontWeight: "600", lineHeight: "1.2", color: "#0ec58a" }}>
{displayData.facilityCount.toLocaleString()} <span style={{ fontSize: "14px", color: "#000000" }}>개소</span>
</div>
</div>
</div>
</div>
{/* 오른쪽: Doughnut 차트 */}
<div className="flex-1">
<div className="card card-border h-full flex flex-col">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium mx-auto">가동률 ({year}년도)</h3>
</div>
<div className="flex-1 flex items-start justify-center">
<div style={{ width: "100px", height: "100px", display: "flex", alignItems: "flex-start", justifyContent: "center" }}>
<Doughnut
data={{
datasets: [{
data: displayData.chartData,
backgroundColor: ['#00DFE5', '#f5f5f5'],
borderWidth: 0
}]
}}
options={{
responsive: true,
maintainAspectRatio: true,
cutout: '60%',
circumference: 180, // 반원
rotation: -90, // 왼쪽에서 시작
layout: {
padding: 0,
},
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
}
}}
key={displayData.operatingRate} // Key를 변경하여 차트 강제 업데이트
plugins={[{
id: 'centerText',
afterDraw: (chart: ChartJS) => {
const ctx = chart.ctx;
const centerX = chart.width / 2;
const centerY = chart.height / 2;
const rate = displayData.operatingRate ?? 0;
// 가동률 텍스트
ctx.save();
ctx.font = 'bold 16px Arial';
ctx.fillStyle = '#00DFE5';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${rate}%`, centerX, centerY + 10);
// 라벨 텍스트
ctx.font = '10px Arial';
ctx.fillStyle = '#666';
ctx.fillText('가동률', centerX, centerY + 28);
ctx.restore();
}
}]}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default FoFcltCntRt;
@@ -1,246 +0,0 @@
"use client";
/**
* 수계별 유입ㆍ방류수질 분석
*/
import React, { useEffect, useState, useMemo } from 'react';
import { useStore } from "zustand";
import { Line } from 'react-chartjs-2';
import BasinSelect from "./basIn-select";
import {
Chart as ChartJS,
CategoryScale, LinearScale, PointElement, LineElement,
Title, Tooltip, Legend, type ChartOptions,
} from 'chart.js';
import type { OperWqltContent } from "../model/fo-interface";
import { OperationApi, basinStore } from "../api/fo-service";
import { defaultInitOperWqltState } from "../api/fo-service";
// Chart.js 등록
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
type WaterQualityType = 'BOD' | 'TOC' | 'SS' | 'TP' | 'TN';
const FoWqltInflEffl: React.FC = () => {
const { selectedBasin, setSelectedBasin } = useStore(basinStore);
const [selectedTab, setSelectedTab] = useState<WaterQualityType>('BOD');
const [wqltData, setWqltData] = useState<OperWqltContent[]>([]);
const tabs: WaterQualityType[] = ['BOD', 'TOC', 'SS', 'TP', 'TN'];
const onBasinChange = (value: string) => {
setSelectedBasin(value);
};
useEffect(() => {
setWqltData(defaultInitOperWqltState.operWqltStatus);
const fetchData = async () => {
try {
const standardYear = await OperationApi.getStandardYear();
const data = await OperationApi.getDashOperWqlt(standardYear);
setWqltData(data);
// console.log(wqltData);
} catch (e) {
console.error("시설 유입ㆍ방류수질 데이터 조회 실패", e);
}
};
fetchData();
}, []);
const chartData = useMemo(() => {
const filteredData = wqltData
.filter(d => d.rvbsn === selectedBasin) // 해당 유역
.sort((a, b) => Number(a.yr) - Number(b.yr));
const wqltKey = selectedTab.charAt(0).toUpperCase() + selectedTab.slice(1).toLowerCase();
const inflowKey = `sumInfl${wqltKey}` as keyof OperWqltContent;
const effluentKey = `sumEffl${wqltKey}` as keyof OperWqltContent;
const inflowAvgKey = `avgInfl${wqltKey}` as keyof OperWqltContent;
const effluentAvgKey = `avgEffl${wqltKey}` as keyof OperWqltContent;
const labels = filteredData.map(d => d.yr ?? '');
const inflowData = filteredData.map(d => d[inflowKey] as number);
const effluentData = filteredData.map(d => d[effluentKey] as number);
const inflowAvg = filteredData[0]?.[inflowAvgKey] as number ?? 0;
const effluentAvg = filteredData[0]?.[effluentAvgKey] as number ?? 0;
return { labels, inflowData, effluentData, inflowAvg, effluentAvg };
}, [wqltData, selectedBasin, selectedTab]);
// console.log(chartData);
// 차트 공통 옵션
// const getChartOptions = (yAxisLabel: string): ChartOptions<'line'> => ({
const getChartOptions = (): ChartOptions<'line'> => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
usePointStyle: true,
boxWidth: 6,
color: '#000',
font: {
size: 8,
},
},
},
tooltip: {
enabled: true,
titleFont: { size: 10 },
bodyFont: { size: 10 },
},
},
scales: {
x: {
grid: {
color: 'rgba(255, 255, 255, 0.1)',
tickLength: 4, // 눈금 길이 축소
},
ticks: {
color: '#000',
font: {
size: 8, // 폰트 크기 축소
},
padding: 0,
},
},
y: {
grid: {
color: 'rgba(255, 255, 255, 0.1)',
},
ticks: {
color: '#000',
font: {
size: 8, // 폰트 크기 축소
},
maxTicksLimit: 4, // 눈금 개수 제한
padding: 2,
},
title: {
display: false,
},
},
},
layout: {
padding: {
top: 5,
bottom: 0,
left: 0,
right: 5
}
}
});
// 유입 데이터 차트 설정
const inflowChartData = {
labels: chartData.labels,
datasets: [
{
label: `유입${selectedTab} (mg/L)`,
data: chartData.inflowData,
borderColor: '#00D4FF',
backgroundColor: '#00D4FF',
borderWidth: 1,
pointRadius: 2,
pointHoverRadius: 4,
tension: 0.4,
},
{
label: '유입 평균 (mg/L)',
data: Array(chartData.labels.length).fill(chartData.inflowAvg),
borderColor: '#FF1493',
backgroundColor: '#FF1493',
borderWidth: 1,
borderDash: [10, 5],
pointRadius: 0,
pointHoverRadius: 4,
tension: 0,
},
],
};
// 방류 데이터 차트 설정
const effluentChartData = {
labels: chartData.labels,
datasets: [
{
label: `방류${selectedTab} (mg/L)`,
data: chartData.effluentData,
borderColor: '#00FF88',
backgroundColor: '#00FF88',
borderWidth: 1,
pointRadius: 2,
pointHoverRadius: 4,
tension: 0.4,
},
{
label: '방류 평균 (mg/L)',
data: Array(chartData.labels.length).fill(chartData.effluentAvg),
borderColor: '#FFB800',
backgroundColor: '#FFB800',
borderWidth: 1,
borderDash: [10, 5],
pointRadius: 0,
pointHoverRadius: 4,
tension: 0,
},
],
};
return (
<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} />
</div>
<div className="h-70 p-0 flex items-center justify-center">
<div style={{ width: "100%", maxWidth: 400, height: 180 }}>
{/* 탭 메뉴 */}
<div style={{ display: 'flex', gap: '20px', borderBottom: '1px solid #000000', flexShrink: 0 }}>
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setSelectedTab(tab)}
style={{
background: 'none',
border: 'none',
color: selectedTab === tab ? '#00D4FF' : '#6b7c93',
fontSize: '8px',
fontWeight: selectedTab === tab ? 'bold' : 'normal',
cursor: 'pointer',
borderBottom: selectedTab === tab ? '3px solid #00D4FF' : 'none',
transition: 'all 0.3s',
}}
>
{tab}
</button>
))}
</div>
{/* 차트 영역 */}
<div style={{ display: 'flex', flexDirection: 'row', flex: 1, minHeight: 0 }}>
{/* 유입 차트 */}
<div style={{ flex: 1, position: 'relative', minWidth: 0 }}>
<Line data={inflowChartData} options={getChartOptions()} />
</div>
{/* 방류 차트 */}
<div style={{ flex: 1, position: 'relative', minWidth: 0 }}>
<Line data={effluentChartData} options={getChartOptions()} />
</div>
</div>
</div>
</div>
</div>
);
};
export default FoWqltInflEffl;
@@ -1,53 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel } from "@tanstack/react-table";
import { PostApi, type MyPost, type NoticePost } from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import {
myPostTableColumns,
} from "../model/table-columns";
import { Button } from "@common/components";
const MyPostList: React.FC = () => {
const router = useRouter();
const myPostQuery = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.MY_POSTS],
queryFn: () =>
PostApi.getMyPostList({
page: 0,
size: 5,
}),
});
const handleMyPostRowClick = (row: MyPost) => {
router.push(`/cms/${row.bbsTypeCd.toLowerCase()}/${row.bbsId}/${row.pstNo}`);
};
const { tableComponent: myPostTableComponent } = useDataTable({
query: myPostQuery,
tableOptions: {
columns: myPostTableColumns,
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
variant: "line",
onRowClick: handleMyPostRowClick
},
});
return (
<div className="card card-border !p-0" style={{ height: "300px" }}>
<div className="card-header !m-0 !border-none text-foreground">
<h3 className="text-2xl font-medium">내 게시글</h3>
</div>
{myPostTableComponent}
</div>
);
};
export default MyPostList;
@@ -1,63 +0,0 @@
"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";
import { Button } from "@common/components";
const NoticeList: React.FC = () => {
const router = useRouter();
const noticePostQuery = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS],
queryFn: () =>
PostApi.getNoticePostList({
page: 0,
size: 3,
keywordType: "bbsTypeCd",
keyword: "BBSNOTICE",
}),
});
const handleNoticePostRowClick = (row: NoticePost) => {
router.push(`/cms/${row.bbsTypeCd.toLowerCase()}/${row.bbsId}/${row.pstNo}`);
};
const handleMoreClick = () => {
try {
router.push('/cms/bbsnotice/7');
} catch (error) {
console.error("공지사항 더보기 이동 실패", error);
}
};
const { tableComponent: noticePostTableComponent } = useDataTable({
query: noticePostQuery,
tableOptions: {
columns: noticePostTableColumns,
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
variant: "line",
onRowClick: handleNoticePostRowClick,
},
});
return (
<div className="card card-border !p-0" style={{ height: "220px" }}>
<div className="card-header !m-0 !border-none text-foreground">
<h3 className="text-2xl font-medium">공지사항</h3>
<Button className="h-[3.2rem]" onClick={handleMoreClick}>더보기</Button>
</div>
{noticePostTableComponent}
</div>
);
};
export default NoticeList;
@@ -1,242 +0,0 @@
"use client";
/**
* 물재이용 차트
*/
import React, { useState, useEffect, useRef } from 'react';
import { useStore } from "zustand";
import { Line } from 'react-chartjs-2';
import { Button } from "@common/components";
import type { ReuseStatusContent } from "../model/fo-interface";
import { OperationApi, reuseStore } from "../api/fo-service";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
type ChartOptions
} from 'chart.js';
// Chart.js 플러그인 등록
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler // 영역 채우기를 위해 필요
);
const ReuseChart: React.FC = () => {
const [activeIndex, setActiveIndex] = useState(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const reuseStatusData = useStore(
reuseStore,
(state) => state.reuseStatus) as ReuseStatusContent[];
useEffect(() => {
OperationApi.getReuseStatus().catch((e) => {
console.error("물재이용 차트 데이터 조회 실패", e);
});
}, []);
const labels = reuseStatusData?.map((r) => r.yr);
const sewageData = reuseStatusData?.map((r) => r.sewage);
const reclaimedData = reuseStatusData?.map((r) => r.reclaimed);
const rainwaterData = reuseStatusData?.map((r) => r.rainwater);
const commonChartData = {
datasets: [
{
label: '재이용량',
data: sewageData, // 기본은 하수처리수
borderColor: '#00D4FF', // 선 색상 (밝은 하늘색)
backgroundColor: 'rgba(0, 212, 255, 0.1)', // 채우기 색상 (투명도 있는 하늘색)
borderWidth: 3,
pointBackgroundColor: '#00D4FF',
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointRadius: 6,
pointHoverRadius: 8,
fill: true, // 아래 영역 채우기 활성화
tension: 0.4, // 곡선 부드럽게
},
],
};
const commonChartOptions: ChartOptions<'line'> = {
maintainAspectRatio: false,
plugins: {
legend: {
display: false, // 범례 숨김 (이미지에 없음)
},
tooltip: {
callbacks: {
label: (context) => {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += new Intl.NumberFormat('ko-KR').format(context.parsed.y) + '㎥';
}
return label;
}
}
}
},
scales: {
x: {
grid: {
display: true,
color: '#f0f0f0', // 연한 회색 그리드
},
ticks: {
color: '#666',
}
},
y: {
min: 1100000, // Y축 최소값 설정 (그래프 모양을 위해)
grid: {
color: '#f0f0f0',
},
ticks: {
color: '#666',
callback: function(value) {
return new Intl.NumberFormat('ko-KR').format(Number(value)) + '㎥';
}
}
}
},
interaction: {
mode: 'index',
intersect: false,
},
};
const chartItems = [
{
title: "하수처리수 재이용량 (m³)",
data: {
...commonChartData,
labels,
datasets: [{ ...commonChartData.datasets[0], data: sewageData }],
},
options: commonChartOptions,
},
{
title: "중수도 재이용량 (m³)",
data: {
...commonChartData,
labels,
datasets: [{ ...commonChartData.datasets[0], data: reclaimedData }],
},
options: commonChartOptions,
},
{
title: "빗물 재이용량 (m³)",
data: {
...commonChartData,
labels,
datasets: [{ ...commonChartData.datasets[0], data: rainwaterData }],
},
options: commonChartOptions,
},
];
const startSlider = () => {
intervalRef.current = setInterval(() => {
setActiveIndex(prevIndex => (prevIndex + 1) % chartItems.length);
}, 7000);
};
const stopSlider = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
useEffect(() => {
startSlider();
return () => stopSlider();
}, []);
const handleIndicatorClick = (index: number) => {
stopSlider();
setActiveIndex(index);
startSlider();
};
const handleMoreClick = () => {
window.open(process.env.NEXT_PUBLIC_WR, "_blank", "noopener,noreferrer");
};
const viewportRef = useRef<HTMLDivElement>(null);
return (
<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 className="h-[3.2rem]" onClick={handleMoreClick}>더보기</Button>
</div>
<div className="relative" style={{ maxWidth: 400, height: 180, marginLeft: 20 }}>
<div className="h-full">
{/* ✅ 좌표 기준 (절대 이동 X) */}
<div
ref={viewportRef}
className="w-full overflow-hidden"
style={{ height: 170}}
>
{/* ✅ 이동 대상 */}
<div
className="flex h-full transition-transform duration-500 ease-in-out"
style={{
width: `${chartItems.length * 100}%`,
transform: `translateX(-${
activeIndex * (viewportRef.current?.offsetWidth ?? 0)
}px)`
}}
>
{chartItems.map((chart, index) => (
<div
key={index}
className="h-full flex-shrink-0"
style={{ width: `${100 / chartItems.length}%` }}
>
<Line
data={chart.data}
options={chart.options as ChartOptions<'line'>}
/>
</div>
))}
</div>
</div>
<div className="absolute left-1/2 -translate-x-1/2 flex space-x-2" style={{ bottom: '-1px'}}>
{chartItems.map((_, index) => (
<button
key={index}
onClick={() => handleIndicatorClick(index)}
className={`w-3 h-3 rounded-full cursor-pointer transition-colors ${
activeIndex === index ? 'bg-blue-500' : 'bg-gray-300'
}`}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
</div>
</div>
</div>
);
};
export default ReuseChart;
@@ -1,85 +0,0 @@
"use client";
/**
* 물재이용 현황
*/
import React, { useEffect } from 'react';
import { useStore } from "zustand";
import { Button } from "@common/components";
import type { ReuseStatusContent } from "../model/fo-interface";
import { OperationApi, reuseStore} from "../api/fo-service";
const ReuseStatus: React.FC = () => {
// const [reuseStatusData, setReuseStatusData] = useState<ReuseStatusContent[]>([]);
const reuseStatusData = useStore(
reuseStore,
(state) => state.reuseStatus ) as ReuseStatusContent[];
useEffect(() => {
OperationApi.getReuseStatus().catch((e) => {
console.error("물재이용 현황 데이터 조회 실패", e);
});
}, []);
// 숫자 포맷 함수 (천단위 쉼표)
const formatNumber = (num: number): string => {
return new Intl.NumberFormat('ko-KR').format(num);
};
const handleMoreClick = () => {
window.open(process.env.NEXT_PUBLIC_WR, "_blank", "noopener,noreferrer");
};
return (
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">물재이용 현황 (재이용량, m³)</h3>
<Button className="h-[3.2rem]" onClick={handleMoreClick}>더보기</Button>
</div>
<div className="h-70 p-4">
<div className="w-full h-full flex items-center justify-center">
<table className="w-full border-collapse text-md">
<thead>
<tr style={{ height: "35px" }}>
<th className="bg-blue-500 text-white font-bold py-1 px-3 border border-gray-300">
년도
</th>
<th className="bg-blue-500 text-white font-bold py-1 px-3 border border-gray-300">
하수재이용량
</th>
<th className="bg-blue-500 text-white font-bold py-1 px-3 border border-gray-300">
중수도이용량
</th>
<th className="bg-blue-500 text-white font-bold py-1 px-3 border border-gray-300">
빗물이용량
</th>
</tr>
</thead>
<tbody>
{reuseStatusData && reuseStatusData.length > 0 && reuseStatusData?.map((row, index) => (
<tr key={index} className="hover:bg-gray-50" style={{ height: "35px" }}>
<td className="bg-blue-100 font-semibold text-center py-1 px-3 border border-gray-300">
{row.yr}
</td>
<td className="text-center py-1 px-3 border border-gray-300 bg-white">
{formatNumber(row.sewage)}
</td>
<td className="text-center py-1 px-3 border border-gray-300 bg-white">
{formatNumber(row.reclaimed)}
</td>
<td className="text-center py-1 px-3 border border-gray-300 bg-white">
{formatNumber(row.rainwater)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default ReuseStatus;
@@ -1,70 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel } from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import {
scheduleTableColumns,
} from "../model/table-columns";
import { getScheduleList } from "../api/schedule-service";
// 날짜를 YYYY-MM-DD 형식으로 포맷팅
const formatDate = (date: Date): string => {
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}`;
};
// 오늘부터 한 달 후까지의 날짜 계산
const getDateRange = () => {
const today = new Date();
const oneMonthLater = new Date();
oneMonthLater.setMonth(today.getMonth() + 1);
return {
startDate: formatDate(today),
endDate: formatDate(oneMonthLater),
};
};
const ScheduleList: React.FC = () => {
const router = useRouter();
const { startDate, endDate } = getDateRange();
const scheduleQuery = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, startDate, endDate],
queryFn: () =>
getScheduleList({
siteId: "",
schdlTtl: "",
schdlSeCd: "",
schdlBgngYmd: startDate,
schdlEndYmd: endDate,
reptYn: "",
commSchdlYn: "Y",
}),
});
const { tableComponent: scheduleTableComponent } = useDataTable({
query: scheduleQuery,
tableOptions: {
columns: scheduleTableColumns,
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
variant: "line",
},
});
return (
<div className="card card-border !p-0" style={{ height: "300px" }}>
{scheduleTableComponent}
</div>
);
};
export default ScheduleList;
@@ -1,184 +0,0 @@
"use client";
/**
* 노후 하수관로 현황
*/
import React, { useEffect, useState } from "react";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
type ChartOptions,
} from "chart.js";
import { Bar } from "react-chartjs-2";
import { Button } from "@common/components";
import type { PplLengthContent, ChartDataSet, OldPplChartData } from "../model/sn-interface";
import { SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service";
// ChartJS 컴포넌트 등록
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
export default function SnOldPpl() {
const [oldPplLenData, setOldPplLenData] = useState<PplLengthContent[]>([]);
const {chartData, setChartData} = useOldPplStore();
const {setMapData} = useMapOldPplStore();
useEffect(() => {
// 유역별, 노후년도별 관로길이 데이터를 한번에 조회
SewerageApi.getMvOldPplLength()
.then((apiResult) => {
setOldPplLenData(apiResult);
})
.catch((e) => console.error("노후관로 데이터 조회 실패", e));
}, []);
useEffect(() => {
if (oldPplLenData.length === 0) return;
// 유역 목록 추출 (중복 제거)
const allZones = [...new Set(oldPplLenData.map(d => d.zoneNm))];
// yearGroup 매핑: API 응답값 -> 차트 label
const yearGroupMap: { [key: string]: { label: string; color: string } } = {
"20년~25년미만": { label: "20-25년", color: "#3B82F6" },
"25년~30년미만": { label: "25-30년", color: "#EF4444" },
"30년이상": { label: "30년이상", color: "#F87171" },
};
// 유역별, 노후년도별 데이터 구조화
const dataByZone: { [zone: string]: { [yearGroup: string]: number } } = {};
allZones.forEach(zone => {
dataByZone[zone] = {};
});
oldPplLenData.forEach(item => {
let zoneData = dataByZone[item.zoneNm];
if (!zoneData) {
zoneData = {};
dataByZone[item.zoneNm] = zoneData;
}
if (item.yearGroup) {
zoneData[item.yearGroup] = item.sumPrlgVl;
}
});
// 데이터셋 생성
const newDatasets: ChartDataSet[] = Object.entries(yearGroupMap).map(([yearGroup, { label, color }]) => ({
label,
data: allZones.map(zone => dataByZone[zone]?.[yearGroup] ?? 0),
backgroundColor: color,
borderRadius: 4,
}));
const newChartData: OldPplChartData = {
labels: allZones,
datasets: newDatasets,
};
setChartData(newChartData);
// 차트 데이터를 기반으로 지도 팝업 데이터 생성 및 상태 업데이트
setMapData(transformChartDataToMapData(newChartData));
}, [oldPplLenData]);
// 각 지역별 총 길이 계산
const totalLengths = chartData.labels.map((label, i) => {
return chartData.datasets.reduce((sum, dataset) => {
const value = dataset.data[i];
return sum + (typeof value === 'number' ? value : 0);
}, 0);
});
// 차트 옵션 설정
const options: ChartOptions<"bar"> = {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
grid: {
display: true,
},
},
y: {
beginAtZero: true,
min: 0,
max: 10000,
ticks: {
stepSize: 2000,
callback: function (value) {
return Number(value).toLocaleString() + 'km';
}
},
grid: {
color: 'rgba(200, 200, 200, 0.3)',
}
}
},
plugins: {
legend: {
position: 'top',
align: 'center', // ⬅ 가운데 정렬
labels: {
boxWidth: 15,
padding: 20, // ⬅ 범례 항목 간 여백 확대
usePointStyle: false,
},
},
tooltip: {
callbacks: {
label: function (context) {
return `${context.dataset.label}: ${Number(context.parsed.y).toLocaleString()}km`;
}
}
},
},
// 총 길이 데이터 표시를 위한 애니메이션 후 함수 설정
animation: {
onComplete: function (animation) {
const chart = animation.chart;
const ctx = chart.ctx;
// 각 데이터셋의 마지막 데이터셋의 인덱스
if (chartData.datasets.length === 0) return;
// const lastMeta = chart.getDatasetMeta(chartData.datasets.length - 1);
// 텍스트 스타일 설정
ctx.font = '11px Arial';
ctx.fillStyle = '#333';
ctx.textAlign = 'center';
// 각 지역별로 총 길이 표시
chartData.labels.forEach((_, i) => {
const xPos = chart.scales.x ? chart.scales.x.getPixelForTick(i) : 0;
const yPos = chart.chartArea.top; // 차트 영역 최상단 위에 표시
// 막대 위에 총 길이 텍스트 표시
if (totalLengths[i]) ctx.fillText(`총 ${totalLengths[i]?.toLocaleString()}km`, xPos, yPos);
});
}
}
};
const handleMoreClick = () => {
window.open(process.env.NEXT_PUBLIC_SN, "_blank", "noopener,noreferrer");
};
return (
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">노후 하수관로 현황</h3>
<Button className="h-[3.2rem]" onClick={handleMoreClick}>더보기</Button>
</div>
<div className="h-70 p-0 flex items-center justify-center">
<div style={{ width: "100%", maxWidth: 400, height: 180 }}>
<Bar options={options} data={chartData} />
</div>
</div>
</div>
);
}
@@ -1,172 +0,0 @@
"use client";
/**
* 오수관로 및 우수관로 현황
*/
import React, { useEffect, useState } from "react";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
type ChartOptions,
} from "chart.js";
import { Bar } from "react-chartjs-2";
import { Button } from "@common/components";
import type { PplLengthContent } from "../model/sn-interface";
import { SewerageApi,useUsePplStore } from "../api/sn-service";
interface ChartDataSet {
label: string;
data: (number | undefined)[];
backgroundColor: string;
borderColor?: string;
}
// const usgCd = { "SBA004": "오수관로", "SBA003": "우수관로", "ETC": "기타" }; // 용도
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
export default function SnPplStatus() {
const {chartData, setChartData} = useUsePplStore();
const [usgPplLenData, setUsgPplLenData] = useState<PplLengthContent[]>([]);
useEffect(() => {
// 유역별, 용도별 관로길이 데이터를 한번에 조회
SewerageApi.getMvUsgPplLength()
.then((apiResult) => {
setUsgPplLenData(apiResult);
})
.catch((e) => console.error("용도별 관로 데이터 조회 실패", e));
}, []);
// 데이터가 변경될 때마다 차트를 다시 계산
useEffect(() => {
if (usgPplLenData.length === 0) return;
// 유역 목록 추출 (중복 제거)
const allZones = [...new Set(usgPplLenData.map(d => d.zoneNm))];
const labels = allZones;
// 유역별, 용도별 데이터 구조화
const dataByZone: { [key: string]: { sewage: number; stormwater: number; etc: number } } = {};
labels.forEach(zone => {
dataByZone[zone] = { sewage: 0, stormwater: 0, etc: 0 };
});
// usgCd별로 데이터 분류: SBA004(오수관로), SBA003(우수관로), ETC(기타)
usgPplLenData.forEach(item => {
if (!dataByZone[item.zoneNm]) {
dataByZone[item.zoneNm] = { sewage: 0, stormwater: 0, etc: 0 };
}
if (item.usgCd === "SBA004") {
dataByZone[item.zoneNm]!.sewage = item.sumPrlgVl;
} else if (item.usgCd === "SBA003") {
dataByZone[item.zoneNm]!.stormwater = item.sumPrlgVl;
} else if (item.usgCd === "ETC") {
dataByZone[item.zoneNm]!.etc = item.sumPrlgVl;
}
});
const datasets: ChartDataSet[] = [];
// 기타 데이터셋 (맨 아래에 쌓기 위해 먼저 추가)
datasets.push({
label: "기타",
data: labels.map(zone => dataByZone[zone]?.etc),
backgroundColor: "rgba(255, 178, 150, 0.9)",
});
// 오수관로 데이터셋
datasets.push({
label: "오수관로",
data: labels.map(zone => dataByZone[zone]?.sewage),
backgroundColor: "rgba(180, 240, 190, 0.95)",
});
// 우수관로 데이터셋
datasets.push({
label: "우수관로",
data: labels.map(zone => dataByZone[zone]?.stormwater),
backgroundColor: "rgba(170, 210, 245, 0.95)",
});
setChartData({ labels, datasets });
}, [usgPplLenData]);
// 총합(라벨당) 계산
const totals = chartData.labels.map((_, i) =>
chartData.datasets.reduce((s, ds) => s + (ds.data[i] as number), 0)
);
const options: ChartOptions<"bar"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top",
},
tooltip: {
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${Number(ctx.parsed.y).toLocaleString()}km`,
},
},
},
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
beginAtZero: true,
ticks: {
callback: (value) => `${Number(value).toLocaleString()}km`,
},
},
},
// 각 스택 위에 총합 표시
animation: {
onComplete: function (animation) {
const chart = animation.chart;
const ctx = chart.ctx;
const lastMeta = chart.getDatasetMeta(chartData.datasets.length - 1);
ctx.save();
ctx.fillStyle = "#111";
ctx.font = "gray bold 12px Arial";
ctx.textAlign = "center";
totals.forEach((total, i) => {
const bar = lastMeta.data[i];
if (!bar) return;
const x = bar.x;
// y는 마지막 스택의 y 좌표(스택의 최상단)에서 조금 위로
const y = bar.y - 8;
ctx.fillText(`총 ${total.toLocaleString()}km`, x, y);
});
ctx.restore();
},
},
};
const handleMoreClick = () => {
window.open(process.env.NEXT_PUBLIC_SN, "_blank", "noopener,noreferrer");
};
return (
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">오수관로 및 우수관로 현황</h3>
<Button className="h-[3.2rem]" onClick={handleMoreClick}>더보기</Button>
</div>
<div className="h-60 p-4">
<div style={{ width: "100%", maxWidth: 400, height: 180 }}>
<Bar options={options} data={chartData} />
</div>
</div>
</div>
);
}
@@ -1,40 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel } from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import type { PageableList } from "@/shared/model";
import { supportTableColumns } from "../model/support-columns";
import { getSupportList, type Support } from "../api/support-service";
const SupportList: React.FC = () => {
const supportQuery = useQuery<PageableList<Support>>({
queryKey: [ENDPOINT.IC_SERVICE.GROUPING_STATISTICS],
queryFn: async () => {
return await getSupportList();
},
});
const { tableComponent: supportTableComponent } = useDataTable({
query: supportQuery,
tableOptions: {
columns: supportTableColumns,
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
variant: "line",
},
});
return (
<div className="card card-border !p-0" style={{ height: "220px" }}>
<div className="card-header !m-0 !border-none text-foreground">
<h3 className="text-2xl font-medium">지원현황</h3>
</div>
{supportTableComponent}
</div>
);
};
export default SupportList;
+4 -19
View File
@@ -44,7 +44,7 @@ export const ENDPOINT = {
ORGANIZATIONS: "/cms-service/api/v1/ognzs",
USER_ORGANIZATION: "/cms-service/api/v1/user-ognz",
IMAGES: "/cms-service/api/v1/images",
BANNERS: "/cms-service/api/v1/bnrs/user",
BANNERS: "/cms-service/api/v1/bnrs",
TEMPLATE: "/cms-service/api/v1/tmplt",
TERMS: "/cms-service/api/v1/trms",
LWPO_CODE: "/cms-service/api/v1/lwpo-cds",
@@ -61,38 +61,23 @@ export const ENDPOINT = {
APILOG: "/cms-service/api/v1/api-logs",
SITE_AUTHORITY: "/cms-service/api/v1/site-authrt",
CONNECTION_LOG: "/cms-service/api/v1/cntnlog",
SCEHDULES: "/cms-service/api/v1/schdls",
},
BOARD_SERVICE: {
BBS: "/board-service/api/v1/bbs",
BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt",
POSTS: "/board-service/api/v1/posts",
MY_POSTS: "/board-service/api/v1/posts/me",
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
POST_EVALUATION: "/board-service/api/v1/pstEvl",
SITE_AUTHORITY_LIST: "/cms-service/api/v1/site-authrt/user",
CONNECTION_LOG_LIST: "/cms-service/api/v1/cntnlog/user",
},
SEWERAGE_SERVICE: {
PPL_LENGTH: "/sewerage-service/api/v1/ms/pplLength",
PPL_USE_LENGTH: "/sewerage-service/api/v1/ms/pplUseLength",
PPL_COUNTS: "/sewerage-service/api/v1/ms/pplCounts",
OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts",
MV_USG_PPL_LENGTH: "/sewerage-service/api/v1/ms/mvUsgPplLength",
MV_OLD_PPL_LENGTH: "/sewerage-service/api/v1/ms/mvOldPplLength",
},
OPERATION_SERVICE: {
MAIN_YEAR: "/operation-service/api/v1/dashBoard/main/year",
OPER_CNTRT: "/operation-service/api/v1/dashBoard/main/oper/cntrt",
OPER_WQLT: "/operation-service/api/v1/dashBoard/main/oper/wqlt",
REUSE_STATUS: "/operation-service/api/v1/dashBoard/main/reuse/status",
REUSE_MAP: "/operation-service/api/v1/dashBoard/main/reuse/map",
},
},
IC_SERVICE: {
GROUPING_STATISTICS: "/ic-service/api/v1/support-status-summary",
GROUPING_STATISTICS: "/ic-service/api/v1/nti/grouping-statistics",
},
DC_SERVICE: {
DATA_STATUS: "/dc-service/api/v1/dataStatus",
DATA_STATUS: "/dc-service/api/v1/dc/dataStatus",
},
};
+14 -15
View File
@@ -1,4 +1,3 @@
import React from "react";
import { type UseQueryResult } from "@tanstack/react-query";
import type { PageableList } from "../model";
@@ -10,24 +9,24 @@ import type { PageableList } from "../model";
export function matchQueryStatus<T>(
query: UseQueryResult<PageableList<T>>,
options: {
Loading?: React.ReactElement;
Errored?: React.ReactElement | ((error: unknown) => React.ReactElement);
Empty?: React.ReactElement;
Loading?: JSX.Element;
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
Empty?: JSX.Element;
Success: (
data: UseQueryResult<PageableList<T>> & {
data: NonNullable<UseQueryResult<T>["data"]>;
},
) => React.ReactElement;
) => JSX.Element;
},
): React.ReactElement;
): JSX.Element;
export function matchQueryStatus<T>(
query: UseQueryResult<PageableList<T>>,
options: {
Loading?: React.ReactElement;
Errored?: React.ReactElement | ((error: unknown) => React.ReactElement);
Success: (data: UseQueryResult<PageableList<T>>) => React.ReactElement;
Loading?: JSX.Element;
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
Success: (data: UseQueryResult<PageableList<T>>) => JSX.Element;
},
): React.ReactElement;
): JSX.Element;
export function matchQueryStatus<T>(
query: UseQueryResult<PageableList<T>>,
{
@@ -36,12 +35,12 @@ export function matchQueryStatus<T>(
Empty,
Success,
}: {
Loading?: React.ReactElement;
Errored?: React.ReactElement | ((error: unknown) => React.ReactElement);
Empty?: React.ReactElement;
Success: (data: UseQueryResult<PageableList<T>>) => React.ReactElement;
Loading?: JSX.Element;
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
Empty?: JSX.Element;
Success: (data: UseQueryResult<PageableList<T>>) => JSX.Element;
},
): React.ReactElement {
): JSX.Element {
if (query.isLoading && Loading) {
return Loading;
}
@@ -1,22 +0,0 @@
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 };
+18 -1
View File
@@ -1,5 +1,22 @@
import { PageTitle } from "@common/components";
import { useMenuStore } from "../lib/use-menu-store";
function PageLayout({ children }: { children: React.ReactNode }) {
return <div className="h-auto min-h-full bg-container pb-2">{children}</div>;
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 };
-15
View File
@@ -1,21 +1,6 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
experimental: {
proxyTimeout: 600000,
},
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
logging: {
fetches: {
fullUrl: true,
},
},
async rewrites() {
return {
afterFiles: [
+3 -3
View File
@@ -23,12 +23,12 @@
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"@tinymce/tinymce-react": "^5.1.1",
"@tinymce/tinymce-react": "^6.1.0",
"axios": "^1.8.1",
"chart.js": "^4.4.8",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"next": "15.3.8",
"next": "15.3.0",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
"react-dnd": "^16.0.1",
@@ -46,7 +46,7 @@
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9",
"eslint-config-next": "15.3.8",
"eslint-config-next": "15.1.7",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because one or more lines are too long
@@ -1,91 +0,0 @@
/**
* Copyright (c) Tiny Technologies, Inc. All rights reserved.
* Licensed under the LGPL or a commercial license.
* For LGPL see License.txt in the project root for license information.
* For commercial licenses see https://www.tiny.cloud/
*/
/**
* Jquery integration plugin.
*
* @class tinymce.core.JqueryIntegration
* @private
*/
!function(){function f(){
// Reference to tinymce needs to be lazily evaluated since tinymce
// might be loaded through the compressor or other means
return d.tinymce}var p,c,u,s=[],d="undefined"!=typeof global?global:window,m=d.jQuery;m.fn.tinymce=function(o){var e,t,i,n,l=this,r="";
// No match then just ignore the call
return l.length?
// Get editor instance
o?(l.css("visibility","hidden"),
// Load TinyMCE on demand, if we need to
d.tinymce||c||!(e=o.script_url)?
// Delay the init call until tinymce is loaded
1===c?s.push(a):a():(c=1,t=e.substring(0,e.lastIndexOf("/")),
// Check if it's a dev/src version they want to load then
// make sure that all plugins, themes etc are loaded in source mode as well
-1!=e.indexOf(".min")&&(r=".min"),
// Setup tinyMCEPreInit object this will later be used by the TinyMCE
// core script to locate other resources like CSS files, dialogs etc
// You can also predefined a tinyMCEPreInit object and then it will use that instead
d.tinymce=d.tinyMCEPreInit||{base:t,suffix:r},
// url contains gzip then we assume it's a compressor
-1!=e.indexOf("gzip")&&(i=o.language||"en",e=e+(/\?/.test(e)?"&":"?")+"js=true&core=true&suffix="+escape(r)+"&themes="+escape(o.theme||"modern")+"&plugins="+escape(o.plugins||"")+"&languages="+(i||""),
// Check if compressor script is already loaded otherwise setup a basic one
d.tinyMCE_GZ||(d.tinyMCE_GZ={start:function(){function n(e){f().ScriptLoader.markDone(f().baseURI.toAbsolute(e))}
// Add core languages
n("langs/"+i+".js"),
// Add themes with languages
n("themes/"+o.theme+"/theme"+r+".js"),n("themes/"+o.theme+"/langs/"+i+".js"),
// Add plugins with languages
m.each(o.plugins.split(","),function(e,t){t&&(n("plugins/"+t+"/plugin"+r+".js"),n("plugins/"+t+"/langs/"+i+".js"))})},end:function(){}})),(n=document.createElement("script")).type="text/javascript",n.onload=n.onreadystatechange=function(e){e=e||window.event,2===c||"load"!=e.type&&!/complete|loaded/.test(n.readyState)||(f().dom.Event.domLoaded=1,c=2,
// Execute callback after mainscript has been loaded and before the initialization occurs
o.script_loaded&&o.script_loaded(),a(),m.each(s,function(e,t){t()}))},n.src=e,document.body.appendChild(n)),l):f()?f().get(l[0].id):null:l;function a(){var a=[],c=0;
// Apply patches to the jQuery object, only once
u||(v(),u=!0),
// Create an editor instance for each matched node
l.each(function(e,t){var n,i=t.id,r=o.oninit;
// Generate unique id for target element if needed
i||(t.id=i=f().DOM.uniqueId()),
// Only init the editor once
f().get(i)||(
// Create editor instance and render it
n=f().createEditor(i,o),a.push(n),n.on("init",function(){var e,t=r;l.css("visibility",""),
// Run this if the oninit setting is defined
// this logic will fire the oninit callback ones each
// matched editor instance is initialized
r&&++c==a.length&&("string"==typeof t&&(e=-1===t.indexOf(".")?null:f().resolve(t.replace(/\.\w+$/,"")),t=f().resolve(t)),
// Call the oninit function with the object
t.apply(e||f(),a))}))}),
// Render the editor instances in a separate loop since we
// need to have the full editors array used in the onInit calls
m.each(a,function(e,t){t.render()})}},
// Add :tinymce pseudo selector this will select elements that has been converted into editor instances
// it's now possible to use things like $('*:tinymce') to get all TinyMCE bound elements.
m.extend(m.expr[":"],{tinymce:function(e){var t;return!!(e.id&&"tinymce"in d&&(t=f().get(e.id))&&t.editorManager===f())}});
// This function patches internal jQuery functions so that if
// you for example remove an div element containing an editor it's
// automatically destroyed by the TinyMCE API
var v=function(){function r(e){
// If the function is remove
"remove"===e&&this.each(function(e,t){var n=u(t);n&&n.remove()}),this.find("span.mceEditor,div.mceEditor").each(function(e,t){var n=f().get(t.id.replace(/_parent$/,""));n&&n.remove()})}function o(i){var e,t=this;
// Handle set value
/*jshint eqnull:true */if(null!=i)r.call(t),
// Saves the contents before get/set value of textarea/div
t.each(function(e,t){var n;(n=f().get(t.id))&&n.setContent(i)});else if(0<t.length&&(e=f().get(t[0].id)))return e.getContent()}function l(e){return e&&e.length&&d.tinymce&&e.is(":tinymce")}
// Removes any child editor instances by looking for editor wrapper elements
var u=function(e){var t=null;return e&&e.id&&d.tinymce?f().get(e.id):t},s={};
// Loads or saves contents from/to textarea if the value
// argument is defined it will set the TinyMCE internal contents
// Patch some setter/getter functions these will
// now be able to set/get the contents of editor instances for
// example $('#editorid').html('Content'); will update the TinyMCE iframe instance
m.each(["text","html","val"],function(e,t){var a=s[t]=m.fn[t],c="text"===t;m.fn[t]=function(e){var t=this;if(!l(t))return a.apply(t,arguments);if(e!==p)return o.call(t.filter(":tinymce"),e),a.apply(t.not(":tinymce"),arguments),t;// return original set for chaining
var i="",r=arguments;return(c?t:t.eq(0)).each(function(e,t){var n=u(t);i+=n?c?n.getContent().replace(/<(?:"[^"]*"|'[^']*'|[^'">])*>/g,""):n.getContent({save:!0}):a.apply(m(t),r)}),i}}),
// Makes it possible to use $('#id').append("content"); to append contents to the TinyMCE editor iframe
m.each(["append","prepend"],function(e,t){var n=s[t]=m.fn[t],r="prepend"===t;m.fn[t]=function(i){var e=this;return l(e)?i!==p?("string"==typeof i&&e.filter(":tinymce").each(function(e,t){var n=u(t);n&&n.setContent(r?i+n.getContent():n.getContent()+i)}),n.apply(e.not(":tinymce"),arguments),e):void 0:n.apply(e,arguments)}}),
// Makes sure that the editor instance gets properly destroyed when the parent element is removed
m.each(["remove","replaceWith","replaceAll","empty"],function(e,t){var n=s[t]=m.fn[t];m.fn[t]=function(){return r.call(this,t),n.apply(this,arguments)}}),s.attr=m.fn.attr,
// Makes sure that $('#tinymce_id').attr('value') gets the editors current HTML contents
m.fn.attr=function(e,t){var n=this,i=arguments;if(!e||"value"!==e||!l(n))return s.attr.apply(n,i);if(t!==p)return o.call(n.filter(":tinymce"),t),s.attr.apply(n.not(":tinymce"),i),n;// return original set for chaining
var r=n[0],a=u(r);return a?a.getContent({save:!0}):s.attr.apply(m(r),i)}}}();
@@ -1,3 +0,0 @@
This is where language files should be placed.
Please DO NOT translate these directly use this service: https://www.transifex.com/projects/p/tinymce/
File diff suppressed because one or more lines are too long
@@ -1,504 +0,0 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 2.1, February 1999
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the Lesser GPL. It also counts
as the successor of the GNU Library Public License, version 2, hence
the version number 2.1.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Lesser General Public License, applies to some
specially designated software packages--typically libraries--of the
Free Software Foundation and other authors who decide to use it. You
can use it too, but we suggest you first think carefully about whether
this license or the ordinary General Public License is the better
strategy to use in any particular case, based on the explanations below.
When we speak of free software, we are referring to freedom of use,
not price. Our General Public Licenses are designed to make sure that
you have the freedom to distribute copies of free software (and charge
for this service if you wish); that you receive source code or can get
it if you want it; that you can change the software and use pieces of
it in new free programs; and that you are informed that you can do
these things.
To protect your rights, we need to make restrictions that forbid
distributors to deny you these rights or to ask you to surrender these
rights. These restrictions translate to certain responsibilities for
you if you distribute copies of the library or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link other code with the library, you must provide
complete object files to the recipients, so that they can relink them
with the library after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
We protect your rights with a two-step method: (1) we copyright the
library, and (2) we offer you this license, which gives you legal
permission to copy, distribute and/or modify the library.
To protect each distributor, we want to make it very clear that
there is no warranty for the free library. Also, if the library is
modified by someone else and passed on, the recipients should know
that what they have is not the original version, so that the original
author's reputation will not be affected by problems that might be
introduced by others.
Finally, software patents pose a constant threat to the existence of
any free program. We wish to make sure that a company cannot
effectively restrict the users of a free program by obtaining a
restrictive license from a patent holder. Therefore, we insist that
any patent license obtained for a version of the library must be
consistent with the full freedom of use specified in this license.
Most GNU software, including some libraries, is covered by the
ordinary GNU General Public License. This license, the GNU Lesser
General Public License, applies to certain designated libraries, and
is quite different from the ordinary General Public License. We use
this license for certain libraries in order to permit linking those
libraries into non-free programs.
When a program is linked with a library, whether statically or using
a shared library, the combination of the two is legally speaking a
combined work, a derivative of the original library. The ordinary
General Public License therefore permits such linking only if the
entire combination fits its criteria of freedom. The Lesser General
Public License permits more lax criteria for linking other code with
the library.
We call this license the "Lesser" General Public License because it
does Less to protect the user's freedom than the ordinary General
Public License. It also provides other free software developers Less
of an advantage over competing non-free programs. These disadvantages
are the reason we use the ordinary General Public License for many
libraries. However, the Lesser license provides advantages in certain
special circumstances.
For example, on rare occasions, there may be a special need to
encourage the widest possible use of a certain library, so that it becomes
a de-facto standard. To achieve this, non-free programs must be
allowed to use the library. A more frequent case is that a free
library does the same job as widely used non-free libraries. In this
case, there is little to gain by limiting the free library to free
software only, so we use the Lesser General Public License.
In other cases, permission to use a particular library in non-free
programs enables a greater number of people to use a large body of
free software. For example, permission to use the GNU C Library in
non-free programs enables many more people to use the whole GNU
operating system, as well as its variant, the GNU/Linux operating
system.
Although the Lesser General Public License is Less protective of the
users' freedom, it does ensure that the user of a program that is
linked with the Library has the freedom and the wherewithal to run
that program using a modified version of the Library.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, whereas the latter must
be combined with the library in order to run.
GNU LESSER GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library or other
program which contains a notice placed by the copyright holder or
other authorized party saying it may be distributed under the terms of
this Lesser General Public License (also called "this License").
Each licensee is addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, complete source code means
all the source code for all modules it contains, plus any associated
interface definition files, plus the scripts used to control compilation
and installation of the library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
appropriate copyright notice and disclaimer of warranty; keep intact
all the notices that refer to this License and to the absence of any
warranty; and distribute a copy of this License along with the
Library.
You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a
fee.
2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Library, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you accompany
it with the complete corresponding machine-readable source code, which
must be distributed under the terms of Sections 1 and 2 above on a
medium customarily used for software interchange.
If distribution of object code is made by offering access to copy
from a designated place, then offering equivalent access to copy the
source code from the same place satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also combine or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (1) uses at run time a
copy of the library already present on the user's computer system,
rather than copying library functions into the executable, and (2)
will operate properly with a modified version of the library, if
the user installs one, as long as the modified version is
interface-compatible with the version that the work was made with.
c) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
d) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
e) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the materials to be distributed need not include anything that is
normally distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library is void, and will automatically terminate your
rights under this License. However, parties who have received copies,
or rights, from you under this License will not have their licenses
terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties with
this License.
11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any
particular circumstance, the balance of the section is intended to apply,
and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library under this License may add
an explicit geographical distribution limitation excluding those countries,
so that distribution is permitted only in or among countries not thus
excluded. In such case, this License incorporates the limitation as if
written in the body of this License.
13. The Free Software Foundation may publish revised and/or new
versions of the Lesser General Public License from time to time.
Such new versions will be similar in spirit to the present version,
but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
copyrighted by the Free Software Foundation, write to the Free
Software Foundation; we sometimes make exceptions for this. Our
decision will be guided by the two goals of preserving the free status
of all derivatives of our free software and of promoting the sharing
and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
<signature of Ty Coon>, 1 April 1990
Ty Coon, President of Vice
That's all there is to it!
@@ -1,9 +0,0 @@
/**
* Copyright (c) Tiny Technologies, Inc. All rights reserved.
* Licensed under the LGPL or a commercial license.
* For LGPL see License.txt in the project root for license information.
* For commercial licenses see https://www.tiny.cloud/
*
* Version: 5.10.9 (2023-11-15)
*/
!function(){"use strict";function f(t,e,n){t.execCommand("UL"===e?"InsertUnorderedList":"InsertOrderedList",!1,!1===n?null:{"list-style-type":n})}function i(t){return function(){return t}}function t(t){return t}function e(){return s}var n=tinymce.util.Tools.resolve("tinymce.PluginManager"),g=tinymce.util.Tools.resolve("tinymce.util.Tools"),u=i(!1),l=i(!0),s={fold:function(t,e){return t()},isSome:u,isNone:l,getOr:t,getOrThunk:r,getOrDie:function(t){throw new Error(t||"error: getOrDie called on none.")},getOrNull:i(null),getOrUndefined:i(void 0),or:t,orThunk:r,map:e,each:function(){},bind:e,exists:u,forall:l,filter:function(){return s},toArray:function(){return[]},toString:i("none()")};function r(t){return t()}function d(t){return t&&/^(TH|TD)$/.test(t.nodeName)}function m(c,a){return function(s){function t(t){var e,n,r,o,i,u,l;s.setActive((e=c,r=a,i=-1!==(o=function(t,e){for(var n=0;n<t.length;n++)if(e(t[n]))return n;return-1}((n=t).parents,d))?n.parents.slice(0,o):n.parents,0<(u=g.grep(i,(l=e,function(t){return t&&/^(OL|UL|DL)$/.test(t.nodeName)&&(e=t,l.$.contains(l.getBody(),e));var e}))).length&&u[0].nodeName===r))}return c.on("NodeChange",t),function(){return c.off("NodeChange",t)}}}function c(t,e,n,r,o,i){var u,l,s,c,a,d;1<i.length?(c=r,a=o,d=i,(s=t).ui.registry.addSplitButton(e,{tooltip:n,icon:"OL"===a?"ordered-list":"unordered-list",presets:"listpreview",columns:3,fetch:function(t){t(g.map(d,function(t){return{type:"choiceitem",value:"default"===t?"":t,icon:"list-"+("OL"===a?"num":"bull")+"-"+("disc"===t||"decimal"===t?"default":t),text:t.replace(/\-/g," ").replace(/\b\w/g,function(t){return t.toUpperCase()})}}))},onAction:function(){return s.execCommand(c)},onItemAction:function(t,e){f(s,a,e)},select:function(e){var t,n=(t=s).dom.getParent(t.selection.getNode(),"ol,ul"),r=t.dom.getStyle(n,"listStyleType");return p(r).map(function(t){return e===t}).getOr(!1)},onSetup:m(s,a)})):(l=r,(u=t).ui.registry.addToggleButton(e,{active:!1,tooltip:n,icon:"OL"===o?"ordered-list":"unordered-list",onSetup:m(u,o),onAction:function(){return u.execCommand(l)}}))}var a=function(n){function t(){return o}function e(t){return t(n)}var r=i(n),o={fold:function(t,e){return e(n)},isSome:l,isNone:u,getOr:r,getOrThunk:r,getOrDie:r,getOrNull:r,getOrUndefined:r,or:t,orThunk:t,map:function(t){return a(t(n))},each:function(t){t(n)},bind:e,exists:e,forall:e,filter:function(t){return t(n)?o:s},toArray:function(){return[n]},toString:function(){return"some("+n+")"}};return o},p=function(t){return null==t?s:a(t)};n.add("advlist",function(t){var n,e,r,o;t.hasPlugin("lists")?(c(e=t,"numlist","Numbered list","InsertOrderedList","OL",(r=e.getParam("advlist_number_styles","default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman"))?r.split(/[ ,]/):[]),c(e,"bullist","Bullet list","InsertUnorderedList","UL",(o=e.getParam("advlist_bullet_styles","default,circle,square"))?o.split(/[ ,]/):[]),(n=t).addCommand("ApplyUnorderedListStyle",function(t,e){f(n,"UL",e["list-style-type"])}),n.addCommand("ApplyOrderedListStyle",function(t,e){f(n,"OL",e["list-style-type"])})):console.error("Please use the Lists plugin together with the Advanced List plugin.")})}();

Some files were not shown because too many files have changed in this diff Show More