Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
2754be3250
|
|
@ -32,7 +32,6 @@ import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
|
|||
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
||||
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
||||
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
|
||||
import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes";
|
||||
import dataRoutes from "./routes/dataRoutes";
|
||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||
|
|
@ -50,6 +49,7 @@ import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
|||
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
||||
import dashboardRoutes from "./routes/dashboardRoutes";
|
||||
import reportRoutes from "./routes/reportRoutes";
|
||||
import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -74,8 +74,8 @@ app.use(
|
|||
})
|
||||
);
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||
|
||||
// 정적 파일 서빙 전에 CORS 미들웨어 추가 (OPTIONS 요청 처리)
|
||||
app.options("/uploads/*", (req, res) => {
|
||||
|
|
@ -175,19 +175,7 @@ app.use("/api/layouts", layoutRoutes);
|
|||
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
||||
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
||||
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
||||
// 메일 수신 라우트 디버깅 - 모든 요청 로깅
|
||||
app.use("/api/mail/receive", (req, res, next) => {
|
||||
console.log(`\n🔍 [MAIL RECEIVE REQUEST]`);
|
||||
console.log(` Method: ${req.method}`);
|
||||
console.log(` URL: ${req.originalUrl}`);
|
||||
console.log(` Path: ${req.path}`);
|
||||
console.log(` Base URL: ${req.baseUrl}`);
|
||||
console.log(` Params: ${JSON.stringify(req.params)}`);
|
||||
console.log(` Query: ${JSON.stringify(req.query)}`);
|
||||
next();
|
||||
});
|
||||
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
||||
app.use("/api/mail/sent", mailSentHistoryRoutes); // 발송 이력
|
||||
app.use("/api/screen", screenStandardRoutes);
|
||||
app.use("/api/data", dataRoutes);
|
||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||
|
|
@ -205,6 +193,7 @@ app.use("/api/external-call-configs", externalCallConfigRoutes);
|
|||
app.use("/api/dataflow", dataflowExecutionRoutes);
|
||||
app.use("/api/dashboards", dashboardRoutes);
|
||||
app.use("/api/admin/reports", reportRoutes);
|
||||
app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* OpenAPI 프록시 라우트
|
||||
* - 외부 API 호출을 프록시하는 라우트
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { OpenApiProxyController } from '../controllers/openApiProxyController';
|
||||
// import { authenticateToken } from '../middleware/authMiddleware'; // 공개 API는 인증 불필요
|
||||
|
||||
const router = Router();
|
||||
const controller = new OpenApiProxyController();
|
||||
|
||||
// 날씨, 환율 등 공개 정보는 인증 없이 접근 가능
|
||||
// router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* GET /api/open-api/weather
|
||||
* 날씨 정보 조회 (인증 불필요)
|
||||
* Query: city (도시명, 기본값: Seoul)
|
||||
*/
|
||||
router.get('/weather', (req, res) => controller.getWeather(req, res));
|
||||
|
||||
/**
|
||||
* GET /api/open-api/exchange-rate
|
||||
* 환율 정보 조회
|
||||
* Query: base (기준 통화, 기본값: KRW), target (대상 통화, 기본값: USD)
|
||||
*/
|
||||
router.get('/exchange-rate', (req, res) => controller.getExchangeRate(req, res));
|
||||
|
||||
/**
|
||||
* POST /api/open-api/geocode
|
||||
* 주소를 좌표로 변환 (Geocoding)
|
||||
* Body: { address: string }
|
||||
*/
|
||||
router.post('/geocode', (req, res) => controller.geocode(req, res));
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
# 기상청 Open API 키 발급 가이드 🇰🇷
|
||||
|
||||
## 📌 개요
|
||||
|
||||
날씨 위젯은 **공공데이터포털 기상청 API**를 사용합니다.
|
||||
- 🌐 **플랫폼**: https://www.data.go.kr
|
||||
- ✅ **완전 무료**
|
||||
- ✅ **일일 트래픽 제한 없음**
|
||||
- ✅ **실시간 한국 날씨 정보**
|
||||
|
||||
> **참고**: 기상청 API Hub (apihub.kma.go.kr)는 현재 접근 제한이 있어,
|
||||
> 공공데이터포털의 기상청 API를 사용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 API 키 발급 (5분 소요)
|
||||
|
||||
### 1️⃣ 공공데이터포털 회원가입
|
||||
|
||||
```
|
||||
👉 https://www.data.go.kr
|
||||
```
|
||||
|
||||
1. 우측 상단 **회원가입** 클릭
|
||||
2. 이메일 입력 및 인증
|
||||
3. 약관 동의 후 가입 완료
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ API 활용신청
|
||||
|
||||
```
|
||||
👉 https://www.data.go.kr/data/15084084/openapi.do
|
||||
```
|
||||
|
||||
**"기상청_단기예보 ((구)_동네예보) 조회서비스"** 페이지에서:
|
||||
|
||||
1. **활용신청** 버튼 클릭
|
||||
2. 활용 목적: `기타`
|
||||
3. 상세 기능 설명: `대시보드 날씨 위젯`
|
||||
4. 신청 완료
|
||||
|
||||
⚠️ **승인까지 약 2-3시간 소요** (즉시 승인되는 경우도 있음)
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ 인증키 확인
|
||||
|
||||
```
|
||||
👉 https://www.data.go.kr/mypage/myPageOpenAPI.do
|
||||
```
|
||||
|
||||
**마이페이지 > 오픈API > 인증키**에서:
|
||||
|
||||
1. **일반 인증키(Encoding)** 복사
|
||||
2. 긴 문자열 전체를 복사하세요!
|
||||
|
||||
**예시:**
|
||||
```
|
||||
aBc1234dEf5678gHi9012jKl3456mNo7890pQr1234sTu5678vWx9012yZa3456bCd7890==
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 환경 변수 설정
|
||||
|
||||
### 방법 1: .env 파일 생성 (추천)
|
||||
|
||||
```bash
|
||||
# 1. .env 파일 생성
|
||||
cd /Users/leeheejin/ERP-node/backend-node
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 2. 다음 내용 입력:
|
||||
|
||||
```bash
|
||||
# Node 환경
|
||||
NODE_ENV=development
|
||||
|
||||
# 서버 포트
|
||||
PORT=8080
|
||||
|
||||
# 기상청 API 키 (발급받은 인증키를 여기에 붙여넣기)
|
||||
KMA_API_KEY=여기에_발급받은_인증키를_붙여넣으세요
|
||||
```
|
||||
|
||||
### 3. 저장 및 종료
|
||||
- `Ctrl + O` (저장)
|
||||
- `Enter` (확인)
|
||||
- `Ctrl + X` (종료)
|
||||
|
||||
---
|
||||
|
||||
### 방법 2: 명령어로 추가
|
||||
|
||||
```bash
|
||||
cd /Users/leeheejin/ERP-node/backend-node
|
||||
|
||||
echo "KMA_API_KEY=여기에_발급받은_인증키_붙여넣기" >> .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 백엔드 재시작
|
||||
|
||||
```bash
|
||||
docker restart pms-backend-mac
|
||||
```
|
||||
|
||||
또는
|
||||
|
||||
```bash
|
||||
cd /Users/leeheejin/ERP-node/backend-node
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 테스트
|
||||
|
||||
### 1. 브라우저에서 대시보드 접속
|
||||
```
|
||||
http://localhost:9771/admin/dashboard
|
||||
```
|
||||
|
||||
### 2. 날씨 위젯 드래그 앤 드롭
|
||||
- 오른쪽 사이드바에서 **☁️ 날씨 위젯** 드래그
|
||||
- 캔버스에 드롭
|
||||
- **실시간 한국 날씨** 표시 확인! 🎉
|
||||
|
||||
### 3. API 직접 테스트
|
||||
```bash
|
||||
curl "http://localhost:9771/api/open-api/weather?city=서울" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"city": "서울",
|
||||
"country": "KR",
|
||||
"temperature": 18,
|
||||
"feelsLike": 16,
|
||||
"humidity": 65,
|
||||
"pressure": 1013,
|
||||
"weatherMain": "Clear",
|
||||
"weatherDescription": "맑음",
|
||||
"weatherIcon": "01d",
|
||||
"windSpeed": 3.5,
|
||||
"clouds": 10,
|
||||
"timestamp": "2025-10-13T07:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌍 지원 지역
|
||||
|
||||
### 한국 주요 도시
|
||||
- **서울** (Seoul)
|
||||
- **부산** (Busan)
|
||||
- **인천** (Incheon)
|
||||
- **대구** (Daegu)
|
||||
- **광주** (Gwangju)
|
||||
- **대전** (Daejeon)
|
||||
- **울산** (Ulsan)
|
||||
- **세종** (Sejong)
|
||||
- **수원** (Suwon)
|
||||
- **춘천** (Chuncheon)
|
||||
- **제주** (Jeju)
|
||||
|
||||
**영문/한글 모두 지원!**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 트러블슈팅
|
||||
|
||||
### 1. "기상청 API 키가 설정되지 않았습니다" 오류
|
||||
|
||||
**원인**: `.env` 파일에 API 키가 없음
|
||||
|
||||
**해결방법**:
|
||||
```bash
|
||||
# .env 파일 확인
|
||||
cat /Users/leeheejin/ERP-node/backend-node/.env
|
||||
|
||||
# KMA_API_KEY가 있는지 확인
|
||||
# 없으면 위 "환경 변수 설정" 참고하여 추가
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. "기상청 API 오류: SERVICE_KEY_IS_NOT_REGISTERED_ERROR" 오류
|
||||
|
||||
**원인**: API 키가 아직 승인되지 않았거나 잘못된 키
|
||||
|
||||
**해결방법**:
|
||||
1. 공공데이터포털에서 승인 상태 확인
|
||||
2. **일반 인증키(Encoding)** 복사했는지 확인 (Decoding 아님!)
|
||||
3. 키 앞뒤에 공백 없는지 확인
|
||||
4. 백엔드 재시작
|
||||
|
||||
---
|
||||
|
||||
### 3. "지원하지 않는 지역입니다" 오류
|
||||
|
||||
**원인**: 등록되지 않은 도시명
|
||||
|
||||
**해결방법**:
|
||||
- 위 "지원 지역" 목록 참고
|
||||
- 영문 또는 한글 정확히 입력
|
||||
- 예: `서울`, `Seoul`, `부산`, `Busan`
|
||||
|
||||
---
|
||||
|
||||
### 4. API 키 재발급
|
||||
|
||||
공공데이터포털에서:
|
||||
1. **마이페이지 > 오픈API**
|
||||
2. 해당 API 찾기
|
||||
3. **상세보기 > 인증키 재발급**
|
||||
|
||||
---
|
||||
|
||||
## 📊 API 사용 현황 확인
|
||||
|
||||
```
|
||||
👉 https://www.data.go.kr/mypage/myPageOpenAPIStatView.do
|
||||
```
|
||||
|
||||
- 일일 트래픽: **무제한** ✅
|
||||
- 서비스 상태: 정상
|
||||
- 응답 속도: 평균 1초 이내
|
||||
|
||||
---
|
||||
|
||||
## 🎨 위젯 커스터마이징
|
||||
|
||||
### 기본 도시 변경
|
||||
|
||||
```typescript
|
||||
// DashboardDesigner.tsx
|
||||
case 'weather': return '부산'; // 원하는 도시로 변경
|
||||
```
|
||||
|
||||
### 새로고침 주기 변경
|
||||
|
||||
```typescript
|
||||
// WeatherWidget.tsx
|
||||
<WeatherWidget
|
||||
city="서울"
|
||||
refreshInterval={300000} // 5분 (밀리초)
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 참고 링크
|
||||
|
||||
- **공공데이터포털**: https://www.data.go.kr
|
||||
- **기상청 API 신청**: https://www.data.go.kr/data/15084084/openapi.do
|
||||
- **마이페이지(인증키)**: https://www.data.go.kr/mypage/myPageOpenAPI.do
|
||||
- **기상청 공식 사이트**: https://www.weather.go.kr
|
||||
|
||||
---
|
||||
|
||||
## 💡 FAQ
|
||||
|
||||
**Q: 승인이 언제 되나요?**
|
||||
A: 보통 **2-3시간**, 빠르면 즉시 승인됩니다.
|
||||
|
||||
**Q: 유료인가요?**
|
||||
A: **완전 무료**입니다! 트래픽 제한도 없어요.
|
||||
|
||||
**Q: 해외 도시도 되나요?**
|
||||
A: 아니요, 기상청 API는 **한국 지역만** 지원합니다.
|
||||
|
||||
**Q: 실시간인가요?**
|
||||
A: 실시간 관측 데이터를 제공합니다 (매시간 업데이트).
|
||||
|
||||
---
|
||||
|
||||
✅ **설정 완료 후 대시보드에서 실시간 한국 날씨를 확인하세요!** 🌤️
|
||||
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
# 날씨 위젯 API 키 설정 가이드 🌤️
|
||||
|
||||
## 📌 개요
|
||||
|
||||
날씨 위젯을 사용하려면 **OpenWeatherMap API 키**가 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 OpenWeatherMap API 키 발급
|
||||
|
||||
### 1. 회원가입
|
||||
- 사이트: https://openweathermap.org/api
|
||||
- 무료 플랜 선택 (Free Plan)
|
||||
- 하루 **60회** 무료 호출 가능 (충분함)
|
||||
|
||||
### 2. API 키 확인
|
||||
- 로그인 후 **API keys** 메뉴로 이동
|
||||
- 자동 생성된 API 키 복사
|
||||
- 또는 **Create Key** 버튼으로 새 키 생성
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 환경 변수 설정
|
||||
|
||||
### 백엔드 `.env` 파일 수정
|
||||
|
||||
```bash
|
||||
# backend-node/.env 파일 열기
|
||||
cd /Users/leeheejin/ERP-node/backend-node
|
||||
vi .env
|
||||
```
|
||||
|
||||
### 다음 내용 추가:
|
||||
|
||||
```bash
|
||||
# OpenWeatherMap API 키
|
||||
OPENWEATHER_API_KEY=your_actual_api_key_here
|
||||
```
|
||||
|
||||
**예시:**
|
||||
```bash
|
||||
OPENWEATHER_API_KEY=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 백엔드 재시작
|
||||
|
||||
```bash
|
||||
docker restart pms-backend-mac
|
||||
```
|
||||
|
||||
또는
|
||||
|
||||
```bash
|
||||
cd /Users/leeheejin/ERP-node/backend-node
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 테스트
|
||||
|
||||
### 1. 브라우저에서 대시보드 설계 도구 접속
|
||||
```
|
||||
http://localhost:9771/admin/dashboard
|
||||
```
|
||||
|
||||
### 2. 날씨 위젯 드래그 앤 드롭
|
||||
- 오른쪽 사이드바에서 **☁️ 날씨 위젯** 찾기
|
||||
- 캔버스로 드래그 앤 드롭
|
||||
- 실시간 날씨 정보 표시 확인
|
||||
|
||||
### 3. API 직접 테스트
|
||||
```bash
|
||||
curl "http://localhost:9771/api/open-api/weather?city=Seoul" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌍 지원 도시
|
||||
|
||||
### 한국 주요 도시
|
||||
- Seoul (서울)
|
||||
- Busan (부산)
|
||||
- Incheon (인천)
|
||||
- Daegu (대구)
|
||||
- Gwangju (광주)
|
||||
- Daejeon (대전)
|
||||
|
||||
### 해외 도시
|
||||
- Tokyo
|
||||
- New York
|
||||
- London
|
||||
- Paris
|
||||
- Singapore
|
||||
|
||||
---
|
||||
|
||||
## 🔧 트러블슈팅
|
||||
|
||||
### 1. "날씨 API 키가 유효하지 않습니다" 오류
|
||||
**원인**: API 키가 잘못되었거나 활성화되지 않음
|
||||
|
||||
**해결방법**:
|
||||
1. OpenWeatherMap 사이트에서 API 키 재확인
|
||||
2. 새로 발급한 키는 **2시간 후** 활성화됨 (대기 필요)
|
||||
3. `.env` 파일에 복사한 키가 정확한지 확인
|
||||
4. 백엔드 재시작
|
||||
|
||||
### 2. "도시를 찾을 수 없습니다" 오류
|
||||
**원인**: 도시명 철자 오류 또는 지원하지 않는 도시
|
||||
|
||||
**해결방법**:
|
||||
- 영문 도시명 사용 (Seoul, Busan 등)
|
||||
- OpenWeatherMap 도시 목록 확인: https://openweathermap.org/find
|
||||
|
||||
### 3. "CORS 오류" 발생
|
||||
**원인**: 프론트엔드-백엔드 통신 문제
|
||||
|
||||
**해결방법**:
|
||||
- 백엔드가 정상 실행 중인지 확인 (`docker ps`)
|
||||
- `backend-node/src/app.ts`의 CORS 설정 확인
|
||||
- 브라우저 개발자 도구에서 요청 URL 확인
|
||||
|
||||
---
|
||||
|
||||
## 📊 API 사용량 확인
|
||||
|
||||
- OpenWeatherMap 대시보드: https://home.openweathermap.org/api_keys
|
||||
- 무료 플랜: 하루 60회 (1분당 1회)
|
||||
- 위젯 새로고침 주기: **10분** (기본값)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 커스터마이징
|
||||
|
||||
### 날씨 위젯 도시 변경
|
||||
```typescript
|
||||
// DashboardDesigner.tsx에서 요소 생성 시
|
||||
const newElement = {
|
||||
...
|
||||
content: "Tokyo", // 원하는 도시명으로 변경
|
||||
};
|
||||
```
|
||||
|
||||
### 새로고침 주기 변경
|
||||
```typescript
|
||||
// WeatherWidget.tsx에서
|
||||
<WeatherWidget
|
||||
city="Seoul"
|
||||
refreshInterval={300000} // 5분 (밀리초)
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 참고 링크
|
||||
|
||||
- OpenWeatherMap API 문서: https://openweathermap.org/current
|
||||
- 무료 API 키 발급: https://openweathermap.org/price
|
||||
- 지원 도시 검색: https://openweathermap.org/find
|
||||
|
||||
---
|
||||
|
||||
✅ **설정 완료 후 대시보드에서 실시간 날씨를 확인하세요!**
|
||||
|
||||
|
|
@ -1,10 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement, QueryResult } from "./types";
|
||||
import { ChartRenderer } from "./charts/ChartRenderer";
|
||||
import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils";
|
||||
|
||||
// 위젯 동적 임포트
|
||||
const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/ExchangeWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
interface CanvasElementProps {
|
||||
element: DashboardElement;
|
||||
isSelected: boolean;
|
||||
|
|
@ -330,16 +342,27 @@ export function CanvasElement({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "weather" ? (
|
||||
// 날씨 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<WeatherWidget city={element.config?.city || "서울"} refreshInterval={600000} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "exchange" ? (
|
||||
// 환율 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<ExchangeWidget
|
||||
baseCurrency={element.config?.baseCurrency || "KRW"}
|
||||
targetCurrency={element.config?.targetCurrency || "USD"}
|
||||
refreshInterval={600000}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// 위젯 렌더링 (기존 방식)
|
||||
// 기타 위젯 렌더링
|
||||
<div
|
||||
className={`flex h-full w-full items-center justify-center p-5 text-center text-sm font-medium text-white ${getContentClass()} `}
|
||||
>
|
||||
<div>
|
||||
<div className="mb-2 text-4xl">
|
||||
{element.type === "widget" && element.subtype === "exchange" && "💱"}
|
||||
{element.type === "widget" && element.subtype === "weather" && "☁️"}
|
||||
</div>
|
||||
<div className="mb-2 text-4xl">🔧</div>
|
||||
<div className="whitespace-pre-line">{element.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* 환율 위젯 컴포넌트
|
||||
* - 실시간 환율 정보를 표시
|
||||
* - 한국은행(BOK) API 연동
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getExchangeRate, ExchangeRateData } from '@/lib/api/openApi';
|
||||
import { TrendingUp, TrendingDown, RefreshCw, ArrowRightLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface ExchangeWidgetProps {
|
||||
baseCurrency?: string;
|
||||
targetCurrency?: string;
|
||||
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
|
||||
}
|
||||
|
||||
export default function ExchangeWidget({
|
||||
baseCurrency = 'KRW',
|
||||
targetCurrency = 'USD',
|
||||
refreshInterval = 600000,
|
||||
}: ExchangeWidgetProps) {
|
||||
const [base, setBase] = useState(baseCurrency);
|
||||
const [target, setTarget] = useState(targetCurrency);
|
||||
const [exchangeRate, setExchangeRate] = useState<ExchangeRateData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
// 지원 통화 목록
|
||||
const currencies = [
|
||||
{ value: 'KRW', label: '🇰🇷 KRW (원)', symbol: '₩' },
|
||||
{ value: 'USD', label: '🇺🇸 USD (달러)', symbol: '$' },
|
||||
{ value: 'EUR', label: '🇪🇺 EUR (유로)', symbol: '€' },
|
||||
{ value: 'JPY', label: '🇯🇵 JPY (엔)', symbol: '¥' },
|
||||
{ value: 'CNY', label: '🇨🇳 CNY (위안)', symbol: '¥' },
|
||||
{ value: 'GBP', label: '🇬🇧 GBP (파운드)', symbol: '£' },
|
||||
];
|
||||
|
||||
// 환율 조회
|
||||
const fetchExchangeRate = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const data = await getExchangeRate(base, target);
|
||||
setExchangeRate(data);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err: any) {
|
||||
console.error('환율 조회 실패:', err);
|
||||
|
||||
let errorMessage = '환율 정보를 가져오는 중 오류가 발생했습니다.';
|
||||
|
||||
if (err.response?.status === 503) {
|
||||
errorMessage = 'API 키가 설정되지 않았습니다. 관리자에게 문의하세요.';
|
||||
} else if (err.response?.status === 401) {
|
||||
errorMessage = 'API 키가 유효하지 않습니다.';
|
||||
} else if (err.response?.data?.message) {
|
||||
errorMessage = err.response.data.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로딩 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
fetchExchangeRate();
|
||||
const interval = setInterval(fetchExchangeRate, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [base, target, refreshInterval]);
|
||||
|
||||
// 통화 스왑
|
||||
const handleSwap = () => {
|
||||
setBase(target);
|
||||
setTarget(base);
|
||||
};
|
||||
|
||||
// 통화 기호 가져오기
|
||||
const getCurrencySymbol = (currency: string) => {
|
||||
return currencies.find((c) => c.value === currency)?.symbol || currency;
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (loading && !exchangeRate) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-green-500" />
|
||||
<p className="text-sm text-gray-600">환율 정보 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error || !exchangeRate) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 rounded-lg border p-6">
|
||||
<TrendingDown className="h-12 w-12 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600 text-center mb-3">{error || '환율 정보를 불러올 수 없습니다.'}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchExchangeRate}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">💱 환율</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{lastUpdated
|
||||
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchExchangeRate}
|
||||
disabled={loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 통화 선택 */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Select value={base} onValueChange={setBase}>
|
||||
<SelectTrigger className="flex-1 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currencies.map((currency) => (
|
||||
<SelectItem key={currency.value} value={currency.value}>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSwap}
|
||||
className="h-10 w-10 p-0 rounded-full hover:bg-white"
|
||||
>
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Select value={target} onValueChange={setTarget}>
|
||||
<SelectTrigger className="flex-1 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currencies.map((currency) => (
|
||||
<SelectItem key={currency.value} value={currency.value}>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 환율 표시 */}
|
||||
<div className="bg-white rounded-lg border p-4 mb-4">
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
{exchangeRate.base === 'KRW' ? '1,000' : '1'} {getCurrencySymbol(exchangeRate.base)} =
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-1">
|
||||
{exchangeRate.base === 'KRW'
|
||||
? (exchangeRate.rate * 1000).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
: exchangeRate.rate.toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{getCurrencySymbol(exchangeRate.target)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 계산 예시 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">10,000 {base}</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{(10000 * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate)).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}{' '}
|
||||
{target}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">100,000 {base}</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{(100000 * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate)).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}{' '}
|
||||
{target}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 출처 */}
|
||||
<div className="mt-4 pt-3 border-t text-center">
|
||||
<p className="text-xs text-gray-400">출처: {exchangeRate.source}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* 날씨 위젯 컴포넌트
|
||||
* - 실시간 날씨 정보를 표시
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getWeather, WeatherData } from '@/lib/api/openApi';
|
||||
import {
|
||||
Cloud,
|
||||
CloudRain,
|
||||
Sun,
|
||||
CloudSnow,
|
||||
Wind,
|
||||
Droplets,
|
||||
Gauge,
|
||||
RefreshCw,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface WeatherWidgetProps {
|
||||
city?: string;
|
||||
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
|
||||
}
|
||||
|
||||
export default function WeatherWidget({
|
||||
city = '서울',
|
||||
refreshInterval = 600000,
|
||||
}: WeatherWidgetProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedCity, setSelectedCity] = useState(city);
|
||||
const [weather, setWeather] = useState<WeatherData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
// 도시 목록 (전국 시/군/구 단위)
|
||||
const cities = [
|
||||
// 서울특별시 (25개 구)
|
||||
{ value: '서울', label: '서울' },
|
||||
{ value: '종로구', label: '서울 종로구' },
|
||||
{ value: '중구', label: '서울 중구' },
|
||||
{ value: '용산구', label: '서울 용산구' },
|
||||
{ value: '성동구', label: '서울 성동구' },
|
||||
{ value: '광진구', label: '서울 광진구' },
|
||||
{ value: '동대문구', label: '서울 동대문구' },
|
||||
{ value: '중랑구', label: '서울 중랑구' },
|
||||
{ value: '성북구', label: '서울 성북구' },
|
||||
{ value: '강북구', label: '서울 강북구' },
|
||||
{ value: '도봉구', label: '서울 도봉구' },
|
||||
{ value: '노원구', label: '서울 노원구' },
|
||||
{ value: '은평구', label: '서울 은평구' },
|
||||
{ value: '서대문구', label: '서울 서대문구' },
|
||||
{ value: '마포구', label: '서울 마포구' },
|
||||
{ value: '양천구', label: '서울 양천구' },
|
||||
{ value: '강서구', label: '서울 강서구' },
|
||||
{ value: '구로구', label: '서울 구로구' },
|
||||
{ value: '금천구', label: '서울 금천구' },
|
||||
{ value: '영등포구', label: '서울 영등포구' },
|
||||
{ value: '동작구', label: '서울 동작구' },
|
||||
{ value: '관악구', label: '서울 관악구' },
|
||||
{ value: '서초구', label: '서울 서초구' },
|
||||
{ value: '강남구', label: '서울 강남구' },
|
||||
{ value: '송파구', label: '서울 송파구' },
|
||||
{ value: '강동구', label: '서울 강동구' },
|
||||
|
||||
// 부산광역시
|
||||
{ value: '부산', label: '부산' },
|
||||
{ value: '해운대구', label: '부산 해운대구' },
|
||||
{ value: '부산진구', label: '부산 부산진구' },
|
||||
{ value: '동래구', label: '부산 동래구' },
|
||||
{ value: '사하구', label: '부산 사하구' },
|
||||
{ value: '금정구', label: '부산 금정구' },
|
||||
{ value: '사상구', label: '부산 사상구' },
|
||||
|
||||
// 인천광역시
|
||||
{ value: '인천', label: '인천' },
|
||||
{ value: '부평구', label: '인천 부평구' },
|
||||
{ value: '계양구', label: '인천 계양구' },
|
||||
{ value: '남동구', label: '인천 남동구' },
|
||||
|
||||
// 대구광역시
|
||||
{ value: '대구', label: '대구' },
|
||||
{ value: '수성구', label: '대구 수성구' },
|
||||
{ value: '달서구', label: '대구 달서구' },
|
||||
|
||||
// 광주광역시
|
||||
{ value: '광주', label: '광주' },
|
||||
{ value: '광산구', label: '광주 광산구' },
|
||||
|
||||
// 대전광역시
|
||||
{ value: '대전', label: '대전' },
|
||||
{ value: '유성구', label: '대전 유성구' },
|
||||
|
||||
// 울산광역시
|
||||
{ value: '울산', label: '울산' },
|
||||
|
||||
// 세종특별자치시
|
||||
{ value: '세종', label: '세종' },
|
||||
|
||||
// 경기도 (주요 도시)
|
||||
{ value: '수원', label: '수원' },
|
||||
{ value: '성남', label: '성남' },
|
||||
{ value: '고양', label: '고양' },
|
||||
{ value: '용인', label: '용인' },
|
||||
{ value: '부천', label: '부천' },
|
||||
{ value: '안산', label: '안산' },
|
||||
{ value: '안양', label: '안양' },
|
||||
{ value: '남양주', label: '남양주' },
|
||||
{ value: '화성', label: '화성' },
|
||||
{ value: '평택', label: '평택' },
|
||||
{ value: '의정부', label: '의정부' },
|
||||
{ value: '시흥', label: '시흥' },
|
||||
{ value: '파주', label: '파주' },
|
||||
{ value: '김포', label: '김포' },
|
||||
{ value: '광명', label: '광명' },
|
||||
|
||||
// 강원도
|
||||
{ value: '춘천', label: '춘천' },
|
||||
{ value: '원주', label: '원주' },
|
||||
{ value: '강릉', label: '강릉' },
|
||||
{ value: '속초', label: '속초' },
|
||||
{ value: '동해', label: '동해' },
|
||||
{ value: '태백', label: '태백' },
|
||||
{ value: '삼척', label: '삼척' },
|
||||
|
||||
// 충청북도
|
||||
{ value: '청주', label: '청주' },
|
||||
{ value: '충주', label: '충주' },
|
||||
{ value: '제천', label: '제천' },
|
||||
|
||||
// 충청남도
|
||||
{ value: '천안', label: '천안' },
|
||||
{ value: '공주', label: '공주' },
|
||||
{ value: '보령', label: '보령' },
|
||||
{ value: '아산', label: '아산' },
|
||||
{ value: '서산', label: '서산' },
|
||||
{ value: '논산', label: '논산' },
|
||||
{ value: '당진', label: '당진' },
|
||||
|
||||
// 전라북도
|
||||
{ value: '전주', label: '전주' },
|
||||
{ value: '군산', label: '군산' },
|
||||
{ value: '익산', label: '익산' },
|
||||
{ value: '정읍', label: '정읍' },
|
||||
{ value: '남원', label: '남원' },
|
||||
{ value: '김제', label: '김제' },
|
||||
|
||||
// 전라남도
|
||||
{ value: '목포', label: '목포' },
|
||||
{ value: '여수', label: '여수' },
|
||||
{ value: '순천', label: '순천' },
|
||||
{ value: '나주', label: '나주' },
|
||||
{ value: '광양', label: '광양' },
|
||||
|
||||
// 경상북도
|
||||
{ value: '포항', label: '포항' },
|
||||
{ value: '경주', label: '경주' },
|
||||
{ value: '김천', label: '김천' },
|
||||
{ value: '안동', label: '안동' },
|
||||
{ value: '구미', label: '구미' },
|
||||
{ value: '영주', label: '영주' },
|
||||
{ value: '영천', label: '영천' },
|
||||
{ value: '상주', label: '상주' },
|
||||
{ value: '문경', label: '문경' },
|
||||
{ value: '경산', label: '경산' },
|
||||
{ value: '울릉도', label: '울릉도' },
|
||||
|
||||
// 경상남도
|
||||
{ value: '창원', label: '창원' },
|
||||
{ value: '진주', label: '진주' },
|
||||
{ value: '통영', label: '통영' },
|
||||
{ value: '사천', label: '사천' },
|
||||
{ value: '김해', label: '김해' },
|
||||
{ value: '밀양', label: '밀양' },
|
||||
{ value: '거제', label: '거제' },
|
||||
{ value: '양산', label: '양산' },
|
||||
|
||||
// 제주특별자치도
|
||||
{ value: '제주', label: '제주' },
|
||||
{ value: '서귀포', label: '서귀포' },
|
||||
];
|
||||
|
||||
// 날씨 정보 가져오기
|
||||
const fetchWeather = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getWeather(selectedCity, 'metric', 'kr');
|
||||
setWeather(data);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err: any) {
|
||||
console.error('날씨 조회 실패:', err);
|
||||
|
||||
// 에러 메시지 추출
|
||||
let errorMessage = '날씨 정보를 가져오는 중 오류가 발생했습니다.';
|
||||
|
||||
if (err.response?.status === 503) {
|
||||
errorMessage = 'API 키가 설정되지 않았습니다. 관리자에게 문의하세요.';
|
||||
} else if (err.response?.status === 401) {
|
||||
errorMessage = 'API 키가 유효하지 않습니다.';
|
||||
} else if (err.response?.status === 404) {
|
||||
errorMessage = `도시를 찾을 수 없습니다: ${city}`;
|
||||
} else if (err.response?.data?.message) {
|
||||
errorMessage = err.response.data.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로딩 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
fetchWeather();
|
||||
const interval = setInterval(fetchWeather, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedCity, refreshInterval]);
|
||||
|
||||
// 도시 변경 핸들러
|
||||
const handleCityChange = (newCity: string) => {
|
||||
setSelectedCity(newCity);
|
||||
};
|
||||
|
||||
// 날씨 아이콘 선택
|
||||
const getWeatherIcon = (weatherMain: string) => {
|
||||
switch (weatherMain.toLowerCase()) {
|
||||
case 'clear':
|
||||
return <Sun className="h-12 w-12 text-yellow-500" />;
|
||||
case 'clouds':
|
||||
return <Cloud className="h-12 w-12 text-gray-400" />;
|
||||
case 'rain':
|
||||
case 'drizzle':
|
||||
return <CloudRain className="h-12 w-12 text-blue-500" />;
|
||||
case 'snow':
|
||||
return <CloudSnow className="h-12 w-12 text-blue-300" />;
|
||||
default:
|
||||
return <Cloud className="h-12 w-12 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (loading && !weather) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border p-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-sm text-gray-600">날씨 정보 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error || !weather) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 rounded-lg border p-6">
|
||||
<Cloud className="h-12 w-12 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600 text-center mb-3">{error || '날씨 정보를 불러올 수 없습니다.'}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchWeather}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between text-lg font-semibold text-gray-900 hover:bg-white/50 h-auto py-1 px-2"
|
||||
>
|
||||
{cities.find((city) => city.value === selectedCity)?.label || '도시 선택'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="도시 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>도시를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{cities.map((city) => (
|
||||
<CommandItem
|
||||
key={city.value}
|
||||
value={city.value}
|
||||
onSelect={(currentValue) => {
|
||||
handleCityChange(currentValue === selectedCity ? selectedCity : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedCity === city.value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{city.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 pl-2">
|
||||
{lastUpdated
|
||||
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchWeather}
|
||||
disabled={loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 날씨 아이콘 및 온도 */}
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{getWeatherIcon(weather.weatherMain)}
|
||||
<div>
|
||||
<div className="text-5xl font-bold text-gray-900">
|
||||
{weather.temperature}°C
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 capitalize">
|
||||
{weather.weatherDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
|
||||
<Wind className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">체감 온도</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{weather.feelsLike}°C
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
|
||||
<Droplets className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">습도</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{weather.humidity}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
|
||||
<Wind className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">풍속</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{weather.windSpeed} m/s
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
|
||||
<Gauge className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">기압</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{weather.pressure} hPa
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -6,7 +6,6 @@ import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|||
import { ComponentConfig } from "@/types/report";
|
||||
import { CanvasComponent } from "./CanvasComponent";
|
||||
import { Ruler } from "./Ruler";
|
||||
import { GridLayer } from "./GridLayer";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function ReportDesignerCanvas() {
|
||||
|
|
@ -33,7 +32,6 @@ export function ReportDesignerCanvas() {
|
|||
undo,
|
||||
redo,
|
||||
showRuler,
|
||||
gridConfig,
|
||||
} = useReportDesigner();
|
||||
|
||||
const [{ isOver }, drop] = useDrop(() => ({
|
||||
|
|
@ -333,16 +331,16 @@ export function ReportDesignerCanvas() {
|
|||
style={{
|
||||
width: `${canvasWidth}mm`,
|
||||
minHeight: `${canvasHeight}mm`,
|
||||
backgroundImage: showGrid
|
||||
? `
|
||||
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)
|
||||
`
|
||||
: undefined,
|
||||
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
|
||||
}}
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
{/* 그리드 레이어 */}
|
||||
<GridLayer
|
||||
gridConfig={gridConfig}
|
||||
pageWidth={canvasWidth * 3.7795} // mm to px
|
||||
pageHeight={canvasHeight * 3.7795}
|
||||
/>
|
||||
|
||||
{/* 페이지 여백 가이드 */}
|
||||
{currentPage && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|||
import { QueryManager } from "./QueryManager";
|
||||
import { SignaturePad } from "./SignaturePad";
|
||||
import { SignatureGenerator } from "./SignatureGenerator";
|
||||
import { GridSettingsPanel } from "./GridSettingsPanel";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
|
|
@ -103,7 +102,7 @@ export function ReportDesignerRightPanel() {
|
|||
<div className="w-[450px] border-l bg-white">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
|
||||
<div className="border-b p-2">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="page" className="gap-1 text-xs">
|
||||
<Settings className="h-3 w-3" />
|
||||
페이지
|
||||
|
|
@ -112,10 +111,6 @@ export function ReportDesignerRightPanel() {
|
|||
<Settings className="h-3 w-3" />
|
||||
속성
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="grid" className="gap-1 text-xs">
|
||||
<Settings className="h-3 w-3" />
|
||||
그리드
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="queries" className="gap-1 text-xs">
|
||||
<Database className="h-3 w-3" />
|
||||
쿼리
|
||||
|
|
@ -1401,15 +1396,6 @@ export function ReportDesignerRightPanel() {
|
|||
</TabsContent>
|
||||
|
||||
{/* 쿼리 탭 */}
|
||||
{/* 그리드 탭 */}
|
||||
<TabsContent value="grid" className="mt-0 h-[calc(100vh-120px)]">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<GridSettingsPanel />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="queries" className="mt-0 h-[calc(100vh-120px)]">
|
||||
<QueryManager />
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from "react";
|
||||
import {
|
||||
ComponentConfig,
|
||||
ReportDetail,
|
||||
ReportLayout,
|
||||
ReportPage,
|
||||
ReportLayoutConfig,
|
||||
GridConfig,
|
||||
} from "@/types/report";
|
||||
import { ComponentConfig, ReportDetail, ReportLayout, ReportPage, ReportLayoutConfig } from "@/types/report";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
snapComponentToGrid,
|
||||
createDefaultGridConfig,
|
||||
calculateGridDimensions,
|
||||
detectGridCollision,
|
||||
} from "@/lib/utils/gridUtils";
|
||||
|
||||
export interface ReportQuery {
|
||||
id: string;
|
||||
|
|
@ -84,10 +71,6 @@ interface ReportDesignerContextType {
|
|||
// 템플릿 적용
|
||||
applyTemplate: (templateId: string) => void;
|
||||
|
||||
// 그리드 관리
|
||||
gridConfig: GridConfig;
|
||||
updateGridConfig: (updates: Partial<GridConfig>) => void;
|
||||
|
||||
// 캔버스 설정
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
|
|
@ -226,50 +209,10 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
[], // ref를 사용하므로 의존성 배열 비움
|
||||
);
|
||||
|
||||
// 그리드 설정
|
||||
const [gridConfig, setGridConfig] = useState<GridConfig>(() => {
|
||||
// 기본 페이지 크기 (A4: 794 x 1123 px at 96 DPI)
|
||||
const defaultPageWidth = 794;
|
||||
const defaultPageHeight = 1123;
|
||||
return createDefaultGridConfig(defaultPageWidth, defaultPageHeight);
|
||||
});
|
||||
|
||||
// gridConfig 업데이트 함수
|
||||
const updateGridConfig = useCallback(
|
||||
(updates: Partial<GridConfig>) => {
|
||||
setGridConfig((prev) => {
|
||||
const newConfig = { ...prev, ...updates };
|
||||
|
||||
// cellWidth나 cellHeight가 변경되면 rows/columns 재계산
|
||||
if (updates.cellWidth || updates.cellHeight) {
|
||||
const pageWidth = currentPage?.width ? currentPage.width * 3.7795275591 : 794; // mm to px
|
||||
const pageHeight = currentPage?.height ? currentPage.height * 3.7795275591 : 1123;
|
||||
const { rows, columns } = calculateGridDimensions(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
newConfig.cellWidth,
|
||||
newConfig.cellHeight,
|
||||
);
|
||||
newConfig.rows = rows;
|
||||
newConfig.columns = columns;
|
||||
}
|
||||
|
||||
return newConfig;
|
||||
});
|
||||
},
|
||||
[currentPage],
|
||||
);
|
||||
|
||||
// 레거시 호환성을 위한 별칭
|
||||
const gridSize = gridConfig.cellWidth;
|
||||
const showGrid = gridConfig.visible;
|
||||
const snapToGrid = gridConfig.snapToGrid;
|
||||
const setGridSize = useCallback(
|
||||
(size: number) => updateGridConfig({ cellWidth: size, cellHeight: size }),
|
||||
[updateGridConfig],
|
||||
);
|
||||
const setShowGrid = useCallback((visible: boolean) => updateGridConfig({ visible }), [updateGridConfig]);
|
||||
const setSnapToGrid = useCallback((snap: boolean) => updateGridConfig({ snapToGrid: snap }), [updateGridConfig]);
|
||||
// 레이아웃 도구 설정
|
||||
const [gridSize, setGridSize] = useState(10); // Grid Snap 크기 (px)
|
||||
const [showGrid, setShowGrid] = useState(true); // Grid 표시 여부
|
||||
const [snapToGrid, setSnapToGrid] = useState(true); // Grid Snap 활성화
|
||||
|
||||
// 눈금자 표시
|
||||
const [showRuler, setShowRuler] = useState(true);
|
||||
|
|
@ -1235,23 +1178,9 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
// 컴포넌트 추가 (현재 페이지에)
|
||||
const addComponent = useCallback(
|
||||
(component: ComponentConfig) => {
|
||||
// 그리드 스냅 적용
|
||||
const snappedComponent = snapComponentToGrid(component, gridConfig);
|
||||
|
||||
// 충돌 감지
|
||||
const currentComponents = currentPage?.components || [];
|
||||
if (detectGridCollision(snappedComponent, currentComponents, gridConfig)) {
|
||||
toast({
|
||||
title: "경고",
|
||||
description: "다른 컴포넌트와 겹칩니다. 다른 위치에 배치해주세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setComponents((prev) => [...prev, snappedComponent]);
|
||||
setComponents((prev) => [...prev, component]);
|
||||
},
|
||||
[setComponents, gridConfig, currentPage, toast],
|
||||
[setComponents],
|
||||
);
|
||||
|
||||
// 컴포넌트 업데이트 (현재 페이지에서)
|
||||
|
|
@ -1259,60 +1188,18 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
(id: string, updates: Partial<ComponentConfig>) => {
|
||||
if (!currentPageId) return;
|
||||
|
||||
setLayoutConfig((prev) => {
|
||||
let hasCollision = false;
|
||||
|
||||
const newPages = prev.pages.map((page) => {
|
||||
if (page.page_id !== currentPageId) return page;
|
||||
|
||||
const newComponents = page.components.map((comp) => {
|
||||
if (comp.id !== id) return comp;
|
||||
|
||||
// 업데이트된 컴포넌트에 그리드 스냅 적용
|
||||
const updated = { ...comp, ...updates };
|
||||
|
||||
// 위치나 크기가 변경된 경우에만 스냅 적용 및 충돌 감지
|
||||
if (
|
||||
updates.x !== undefined ||
|
||||
updates.y !== undefined ||
|
||||
updates.width !== undefined ||
|
||||
updates.height !== undefined
|
||||
) {
|
||||
const snapped = snapComponentToGrid(updated, gridConfig);
|
||||
|
||||
// 충돌 감지 (자신을 제외한 다른 컴포넌트와)
|
||||
const otherComponents = page.components.filter((c) => c.id !== id);
|
||||
if (detectGridCollision(snapped, otherComponents, gridConfig)) {
|
||||
hasCollision = true;
|
||||
return comp; // 충돌 시 원래 상태 유지
|
||||
setLayoutConfig((prev) => ({
|
||||
pages: prev.pages.map((page) =>
|
||||
page.page_id === currentPageId
|
||||
? {
|
||||
...page,
|
||||
components: page.components.map((comp) => (comp.id === id ? { ...comp, ...updates } : comp)),
|
||||
}
|
||||
|
||||
return snapped;
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
return {
|
||||
...page,
|
||||
components: newComponents,
|
||||
};
|
||||
});
|
||||
|
||||
// 충돌이 감지된 경우 토스트 메시지 표시 및 업데이트 취소
|
||||
if (hasCollision) {
|
||||
toast({
|
||||
title: "경고",
|
||||
description: "다른 컴포넌트와 겹칩니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
|
||||
return { pages: newPages };
|
||||
});
|
||||
: page,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[currentPageId, gridConfig, toast],
|
||||
[currentPageId],
|
||||
);
|
||||
|
||||
// 컴포넌트 삭제 (현재 페이지에서)
|
||||
|
|
@ -1426,36 +1313,14 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`);
|
||||
}
|
||||
|
||||
// 백엔드 호환성을 위해 첫 번째 페이지 정보를 레거시 필드로 변환
|
||||
const firstPage = layoutConfig.pages[0];
|
||||
const legacyFormat = firstPage
|
||||
? {
|
||||
canvasWidth: firstPage.width,
|
||||
canvasHeight: firstPage.height,
|
||||
pageOrientation: firstPage.orientation,
|
||||
components: firstPage.components,
|
||||
margins: firstPage.margins,
|
||||
// 새로운 페이지 기반 구조도 함께 전송
|
||||
layoutConfig,
|
||||
queries: queries.map((q) => ({
|
||||
...q,
|
||||
externalConnectionId: q.externalConnectionId || undefined,
|
||||
})),
|
||||
}
|
||||
: {
|
||||
canvasWidth: 210,
|
||||
canvasHeight: 297,
|
||||
pageOrientation: "portrait" as const,
|
||||
components: [],
|
||||
layoutConfig,
|
||||
queries: queries.map((q) => ({
|
||||
...q,
|
||||
externalConnectionId: q.externalConnectionId || undefined,
|
||||
})),
|
||||
};
|
||||
|
||||
// 레이아웃 저장
|
||||
await reportApi.saveLayout(actualReportId, legacyFormat);
|
||||
// 레이아웃 저장 (페이지 구조로)
|
||||
await reportApi.saveLayout(actualReportId, {
|
||||
layoutConfig, // 페이지 기반 구조
|
||||
queries: queries.map((q) => ({
|
||||
...q,
|
||||
externalConnectionId: q.externalConnectionId || undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "성공",
|
||||
|
|
@ -1676,9 +1541,6 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
// 그룹화
|
||||
groupComponents,
|
||||
ungroupComponents,
|
||||
// 그리드 관리
|
||||
gridConfig,
|
||||
updateGridConfig,
|
||||
};
|
||||
|
||||
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ const getApiBaseUrl = (): string => {
|
|||
const currentHost = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
|
||||
// 🎯 로컬 개발환경: Next.js 프록시 사용 (대용량 요청 안정성)
|
||||
// 로컬 개발환경: localhost:9771 또는 localhost:3000 → localhost:8080
|
||||
if (
|
||||
(currentHost === "localhost" || currentHost === "127.0.0.1") &&
|
||||
(currentPort === "9771" || currentPort === "3000")
|
||||
) {
|
||||
return "/api"; // 프록시 사용
|
||||
return "http://localhost:8080/api";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* OpenAPI 클라이언트
|
||||
* - 외부 API(날씨, 환율 등) 호출
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
// ============================================================
|
||||
// 타입 정의
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 날씨 정보
|
||||
*/
|
||||
export interface WeatherData {
|
||||
city: string;
|
||||
country: string;
|
||||
temperature: number;
|
||||
feelsLike: number;
|
||||
humidity: number;
|
||||
pressure: number;
|
||||
weatherMain: string;
|
||||
weatherDescription: string;
|
||||
weatherIcon: string;
|
||||
windSpeed: number;
|
||||
clouds: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 환율 정보
|
||||
*/
|
||||
export interface ExchangeRateData {
|
||||
base: string;
|
||||
target: string;
|
||||
rate: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocoding 결과
|
||||
*/
|
||||
export interface GeocodeData {
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 타입
|
||||
*/
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API 함수
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 날씨 정보 조회
|
||||
* @param city 도시명 (기본값: Seoul)
|
||||
* @param units 단위 (metric: 섭씨, imperial: 화씨)
|
||||
* @param lang 언어 (kr: 한국어, en: 영어)
|
||||
*/
|
||||
export async function getWeather(
|
||||
city: string = '서울',
|
||||
units: string = 'metric',
|
||||
lang: string = 'kr'
|
||||
): Promise<WeatherData> {
|
||||
const response = await apiClient.get<ApiResponse<WeatherData>>(
|
||||
`/open-api/weather`,
|
||||
{
|
||||
params: { city, units, lang },
|
||||
}
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 환율 정보 조회
|
||||
* @param base 기준 통화 (기본값: KRW)
|
||||
* @param target 대상 통화 (기본값: USD)
|
||||
*/
|
||||
export async function getExchangeRate(
|
||||
base: string = 'KRW',
|
||||
target: string = 'USD'
|
||||
): Promise<ExchangeRateData> {
|
||||
const response = await apiClient.get<ApiResponse<ExchangeRateData>>(
|
||||
`/open-api/exchange-rate`,
|
||||
{
|
||||
params: { base, target },
|
||||
}
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주소를 좌표로 변환 (Geocoding)
|
||||
* @param address 주소
|
||||
*/
|
||||
export async function geocode(address: string): Promise<GeocodeData> {
|
||||
const response = await apiClient.post<ApiResponse<GeocodeData>>(
|
||||
`/open-api/geocode`,
|
||||
{ address }
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
|
|
@ -81,18 +81,6 @@ export interface ExternalConnection {
|
|||
is_active: string;
|
||||
}
|
||||
|
||||
// 그리드 설정
|
||||
export interface GridConfig {
|
||||
cellWidth: number; // 그리드 셀 너비 (px)
|
||||
cellHeight: number; // 그리드 셀 높이 (px)
|
||||
rows: number; // 세로 그리드 수 (계산값: pageHeight / cellHeight)
|
||||
columns: number; // 가로 그리드 수 (계산값: pageWidth / cellHeight)
|
||||
visible: boolean; // 그리드 표시 여부
|
||||
snapToGrid: boolean; // 그리드 스냅 활성화 여부
|
||||
gridColor: string; // 그리드 선 색상
|
||||
gridOpacity: number; // 그리드 투명도 (0-1)
|
||||
}
|
||||
|
||||
// 페이지 설정
|
||||
export interface ReportPage {
|
||||
page_id: string;
|
||||
|
|
@ -108,7 +96,6 @@ export interface ReportPage {
|
|||
right: number;
|
||||
};
|
||||
background_color: string;
|
||||
gridConfig?: GridConfig; // 그리드 설정 (옵셔널)
|
||||
components: ComponentConfig[];
|
||||
}
|
||||
|
||||
|
|
@ -126,11 +113,6 @@ export interface ComponentConfig {
|
|||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
// 그리드 좌표 (옵셔널)
|
||||
gridX?: number; // 시작 열 (0부터 시작)
|
||||
gridY?: number; // 시작 행 (0부터 시작)
|
||||
gridWidth?: number; // 차지하는 열 수
|
||||
gridHeight?: number; // 차지하는 행 수
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
fontWeight?: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue