Merge branch 'logistream' of http://39.117.244.52:3000/kjs/ERP-node into logi
This commit is contained in:
commit
b11f02aceb
51
Dockerfile
51
Dockerfile
|
|
@ -39,8 +39,10 @@ RUN npm ci && \
|
|||
COPY frontend/ ./
|
||||
|
||||
# Next.js 프로덕션 빌드 (린트 비활성화)
|
||||
# 빌드 시점에 환경변수 설정 (번들에 포함됨)
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api"
|
||||
RUN npm run build:no-lint
|
||||
|
||||
# ------------------------------
|
||||
|
|
@ -66,9 +68,19 @@ COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/package.json ./
|
|||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./frontend/public
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs
|
||||
|
||||
# 업로드 디렉토리 생성 (백엔드용)
|
||||
RUN mkdir -p /app/backend/uploads && \
|
||||
chown -R nodejs:nodejs /app/backend/uploads
|
||||
# 백엔드 디렉토리 생성 (업로드, 로그, 데이터)
|
||||
# /app/uploads, /app/data 경로는 백엔드 코드에서 동적으로 하위 디렉토리 생성
|
||||
# 상위 디렉토리에 쓰기 권한 부여하여 런타임에 자유롭게 생성 가능하도록 함
|
||||
RUN mkdir -p /app/backend/uploads /app/backend/logs /app/backend/data \
|
||||
/app/uploads /app/data && \
|
||||
chown -R nodejs:nodejs /app/backend /app/uploads /app/data && \
|
||||
chmod -R 777 /app/uploads /app/data && \
|
||||
chmod -R 755 /app/backend
|
||||
|
||||
# 프론트엔드 standalone 모드를 위한 디렉토리 생성
|
||||
RUN mkdir -p /app/frontend/data && \
|
||||
chown -R nodejs:nodejs /app/frontend && \
|
||||
chmod -R 755 /app/frontend
|
||||
|
||||
# 시작 스크립트 생성
|
||||
RUN echo '#!/bin/sh' > /app/start.sh && \
|
||||
|
|
@ -77,29 +89,44 @@ RUN echo '#!/bin/sh' > /app/start.sh && \
|
|||
echo '# 백엔드 시작 (백그라운드)' >> /app/start.sh && \
|
||||
echo 'cd /app/backend' >> /app/start.sh && \
|
||||
echo 'echo "Starting backend on port 8080..."' >> /app/start.sh && \
|
||||
echo 'node dist/app.js &' >> /app/start.sh && \
|
||||
echo 'PORT=8080 node dist/app.js &' >> /app/start.sh && \
|
||||
echo 'BACKEND_PID=$!' >> /app/start.sh && \
|
||||
echo '' >> /app/start.sh && \
|
||||
echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \
|
||||
echo 'cd /app/frontend' >> /app/start.sh && \
|
||||
echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \
|
||||
echo 'npm start &' >> /app/start.sh && \
|
||||
echo 'FRONTEND_PID=$!' >> /app/start.sh && \
|
||||
echo '' >> /app/start.sh && \
|
||||
echo '# 프로세스 모니터링' >> /app/start.sh && \
|
||||
echo 'wait $BACKEND_PID $FRONTEND_PID' >> /app/start.sh && \
|
||||
echo 'PORT=3000 exec npm start' >> /app/start.sh && \
|
||||
chmod +x /app/start.sh && \
|
||||
chown nodejs:nodejs /app/start.sh
|
||||
|
||||
# ============================================================
|
||||
# 환경변수 설정 (임시 조치)
|
||||
# helm-charts의 values_logistream.yaml 관리자가 설정 완료 시 삭제 예정
|
||||
# ============================================================
|
||||
ENV NODE_ENV=production \
|
||||
LOG_LEVEL=info \
|
||||
HOST=0.0.0.0 \
|
||||
DATABASE_URL="postgresql://postgres:ph0909!!@39.117.244.52:11132/plm" \
|
||||
JWT_SECRET="ilshin-plm-super-secret-jwt-key-2024" \
|
||||
ENCRYPTION_KEY="ilshin-plm-mail-encryption-key-32characters-2024-secure" \
|
||||
JWT_EXPIRES_IN="24h" \
|
||||
CORS_CREDENTIALS="true" \
|
||||
CORS_ORIGIN="https://logistream.kpslp.kr" \
|
||||
KMA_API_KEY="ogdXr2e9T4iHV69nvV-IwA" \
|
||||
ITS_API_KEY="d6b9befec3114d648284674b8fddcc32" \
|
||||
NEXT_TELEMETRY_DISABLED="1" \
|
||||
NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api"
|
||||
|
||||
# 비특권 사용자로 전환
|
||||
USER nodejs
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 3000 8080
|
||||
|
||||
# 헬스체크
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
# 헬스체크 (백엔드와 프론트엔드 둘 다 확인)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health && \
|
||||
wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
|
||||
|
||||
# 컨테이너 시작
|
||||
CMD ["/app/start.sh"]
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
"redis": "^4.6.10",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -10642,16 +10642,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
"redis": "^4.6.10",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ app.listen(PORT, HOST, async () => {
|
|||
|
||||
// 배치 스케줄러 초기화
|
||||
try {
|
||||
await BatchSchedulerService.initialize();
|
||||
await BatchSchedulerService.initializeScheduler();
|
||||
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { Response } from "express";
|
||||
import https from "https";
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { DashboardService } from "../services/DashboardService";
|
||||
import {
|
||||
|
|
@ -7,6 +10,7 @@ import {
|
|||
DashboardListQuery,
|
||||
} from "../types/dashboard";
|
||||
import { PostgreSQLService } from "../database/PostgreSQLService";
|
||||
import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
|
||||
|
||||
/**
|
||||
* 대시보드 컨트롤러
|
||||
|
|
@ -415,7 +419,7 @@ export class DashboardController {
|
|||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||
search: req.query.search as string,
|
||||
category: req.query.category as string,
|
||||
createdBy: userId, // 본인이 만든 대시보드만
|
||||
// createdBy 제거 - 회사 대시보드 전체 표시
|
||||
};
|
||||
|
||||
const result = await DashboardService.getDashboards(
|
||||
|
|
@ -590,7 +594,14 @@ export class DashboardController {
|
|||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { url, method = "GET", headers = {}, queryParams = {} } = req.body;
|
||||
const {
|
||||
url,
|
||||
method = "GET",
|
||||
headers = {},
|
||||
queryParams = {},
|
||||
body,
|
||||
externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함
|
||||
} = req.body;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
res.status(400).json({
|
||||
|
|
@ -608,85 +619,131 @@ export class DashboardController {
|
|||
}
|
||||
});
|
||||
|
||||
// 외부 API 호출 (타임아웃 30초)
|
||||
// @ts-ignore - node-fetch dynamic import
|
||||
const fetch = (await import("node-fetch")).default;
|
||||
|
||||
// 타임아웃 설정 (Node.js 글로벌 AbortController 사용)
|
||||
const controller = new (global as any).AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림)
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(urlObj.toString(), {
|
||||
// Axios 요청 설정
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
url: urlObj.toString(),
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...headers,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
} catch (err: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('외부 API 요청 타임아웃 (30초 초과)');
|
||||
}
|
||||
throw err;
|
||||
timeout: 60000, // 60초 타임아웃
|
||||
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
||||
};
|
||||
|
||||
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
||||
if (externalConnectionId) {
|
||||
try {
|
||||
// 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도
|
||||
let companyCode = req.user?.companyCode;
|
||||
|
||||
if (!companyCode) {
|
||||
companyCode = "*";
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// 커넥션 로드
|
||||
const connectionResult =
|
||||
await ExternalRestApiConnectionService.getConnectionById(
|
||||
Number(externalConnectionId),
|
||||
companyCode
|
||||
);
|
||||
|
||||
if (connectionResult.success && connectionResult.data) {
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 인증 헤더 생성 (DB 토큰 등)
|
||||
const authHeaders =
|
||||
await ExternalRestApiConnectionService.getAuthHeaders(
|
||||
connection.auth_type,
|
||||
connection.auth_config,
|
||||
connection.company_code
|
||||
);
|
||||
|
||||
// 기존 헤더에 인증 헤더 병합
|
||||
requestConfig.headers = {
|
||||
...requestConfig.headers,
|
||||
...authHeaders,
|
||||
};
|
||||
|
||||
// API Key가 Query Param인 경우 처리
|
||||
if (
|
||||
connection.auth_type === "api-key" &&
|
||||
connection.auth_config?.keyLocation === "query" &&
|
||||
connection.auth_config?.keyName &&
|
||||
connection.auth_config?.keyValue
|
||||
) {
|
||||
const currentUrl = new URL(requestConfig.url!);
|
||||
currentUrl.searchParams.append(
|
||||
connection.auth_config.keyName,
|
||||
connection.auth_config.keyValue
|
||||
);
|
||||
requestConfig.url = currentUrl.toString();
|
||||
}
|
||||
}
|
||||
} catch (connError) {
|
||||
logger.error(
|
||||
`외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`,
|
||||
connError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Body 처리
|
||||
if (body) {
|
||||
requestConfig.data = body;
|
||||
}
|
||||
|
||||
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
|
||||
// ExternalRestApiConnectionService와 동일한 로직 적용
|
||||
const bypassDomains = ["thiratis.com"];
|
||||
const hostname = urlObj.hostname;
|
||||
const shouldBypassTls = bypassDomains.some((domain) =>
|
||||
hostname.includes(domain)
|
||||
);
|
||||
|
||||
if (shouldBypassTls) {
|
||||
requestConfig.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await axios(requestConfig);
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new Error(
|
||||
`외부 API 오류: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
// Content-Type에 따라 응답 파싱
|
||||
const contentType = response.headers.get("content-type");
|
||||
let data: any;
|
||||
let data = response.data;
|
||||
const contentType = response.headers["content-type"];
|
||||
|
||||
// 한글 인코딩 처리 (EUC-KR → UTF-8)
|
||||
const isKoreanApi = urlObj.hostname.includes('kma.go.kr') ||
|
||||
urlObj.hostname.includes('data.go.kr');
|
||||
|
||||
if (isKoreanApi) {
|
||||
// 한국 정부 API는 EUC-KR 인코딩 사용
|
||||
const buffer = await response.arrayBuffer();
|
||||
const decoder = new TextDecoder('euc-kr');
|
||||
const text = decoder.decode(buffer);
|
||||
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { text, contentType };
|
||||
}
|
||||
} else if (contentType && contentType.includes("application/json")) {
|
||||
data = await response.json();
|
||||
} else if (contentType && contentType.includes("text/")) {
|
||||
// 텍스트 응답 (CSV, 일반 텍스트 등)
|
||||
const text = await response.text();
|
||||
data = { text, contentType };
|
||||
} else {
|
||||
// 기타 응답 (JSON으로 시도)
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
const text = await response.text();
|
||||
data = { text, contentType };
|
||||
}
|
||||
// 텍스트 응답인 경우 포맷팅
|
||||
if (typeof data === "string") {
|
||||
data = { text: data, contentType };
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const status = error.response?.status || 500;
|
||||
const message = error.response?.statusText || error.message;
|
||||
|
||||
logger.error("외부 API 호출 오류:", {
|
||||
message,
|
||||
status,
|
||||
data: error.response?.data,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 API 호출 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
? message
|
||||
: "외부 API 호출 오류",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -384,4 +384,72 @@ export class AuthController {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/signup
|
||||
* 회원가입 API (공차중계용)
|
||||
*/
|
||||
static async signup(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body;
|
||||
|
||||
logger.info(`=== 회원가입 API 호출 ===`);
|
||||
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}, vehicleType: ${vehicleType}`);
|
||||
|
||||
// 입력값 검증
|
||||
if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "모든 필수 항목을 입력해주세요.",
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "필수 입력값이 누락되었습니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회원가입 처리
|
||||
const signupResult = await AuthService.signupUser({
|
||||
userId,
|
||||
password,
|
||||
userName,
|
||||
phoneNumber,
|
||||
licenseNumber,
|
||||
vehicleNumber,
|
||||
vehicleType, // 차량 타입 추가
|
||||
});
|
||||
|
||||
if (signupResult.success) {
|
||||
logger.info(`회원가입 성공: ${userId}`);
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "회원가입이 완료되었습니다.",
|
||||
data: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logger.warn(`회원가입 실패: ${userId} - ${signupResult.message}`);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: signupResult.message || "회원가입에 실패했습니다.",
|
||||
error: {
|
||||
code: "SIGNUP_FAILED",
|
||||
details: signupResult.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("회원가입 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "회원가입 처리 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SIGNUP_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -594,7 +594,7 @@ export class BatchManagementController {
|
|||
if (result.success && result.data) {
|
||||
// 스케줄러에 자동 등록 ✅
|
||||
try {
|
||||
await BatchSchedulerService.scheduleBatchConfig(result.data);
|
||||
await BatchSchedulerService.scheduleBatch(result.data);
|
||||
console.log(
|
||||
`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,11 +22,19 @@ export const getLayouts = async (
|
|||
LEFT JOIN user_info u1 ON l.created_by = u1.user_id
|
||||
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
|
||||
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
|
||||
WHERE l.company_code = $1
|
||||
`;
|
||||
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 최고 관리자는 모든 레이아웃 조회 가능
|
||||
if (companyCode && companyCode !== '*') {
|
||||
query += ` WHERE l.company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
} else {
|
||||
query += ` WHERE 1=1`;
|
||||
}
|
||||
|
||||
if (externalDbConnectionId) {
|
||||
query += ` AND l.external_db_connection_id = $${paramIndex}`;
|
||||
|
|
@ -75,14 +83,27 @@ export const getLayoutById = async (
|
|||
const companyCode = req.user?.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
// 레이아웃 기본 정보
|
||||
const layoutQuery = `
|
||||
// 레이아웃 기본 정보 - 최고 관리자는 모든 레이아웃 조회 가능
|
||||
let layoutQuery: string;
|
||||
let layoutParams: any[];
|
||||
|
||||
if (companyCode && companyCode !== '*') {
|
||||
layoutQuery = `
|
||||
SELECT l.*
|
||||
FROM digital_twin_layout l
|
||||
WHERE l.id = $1 AND l.company_code = $2
|
||||
`;
|
||||
layoutParams = [id, companyCode];
|
||||
} else {
|
||||
layoutQuery = `
|
||||
SELECT l.*
|
||||
FROM digital_twin_layout l
|
||||
WHERE l.id = $1
|
||||
`;
|
||||
layoutParams = [id];
|
||||
}
|
||||
|
||||
const layoutResult = await pool.query(layoutQuery, [id, companyCode]);
|
||||
const layoutResult = await pool.query(layoutQuery, layoutParams);
|
||||
|
||||
if (layoutResult.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
|
|
|
|||
|
|
@ -41,4 +41,10 @@ router.post("/logout", AuthController.logout);
|
|||
*/
|
||||
router.post("/refresh", AuthController.refreshToken);
|
||||
|
||||
/**
|
||||
* POST /api/auth/signup
|
||||
* 회원가입 API
|
||||
*/
|
||||
router.post("/signup", AuthController.signup);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -178,21 +178,24 @@ export class DashboardService {
|
|||
let params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 코드 필터링 (최우선)
|
||||
// 회사 코드 필터링 - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
|
||||
if (companyCode) {
|
||||
if (companyCode === '*') {
|
||||
// 최고 관리자는 모든 대시보드 조회 가능
|
||||
} else {
|
||||
whereConditions.push(`d.company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 권한 필터링
|
||||
if (userId) {
|
||||
} else if (userId) {
|
||||
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
|
||||
whereConditions.push(
|
||||
`(d.created_by = $${paramIndex} OR d.is_public = true)`
|
||||
);
|
||||
params.push(userId);
|
||||
paramIndex++;
|
||||
} else {
|
||||
// 비로그인 사용자는 공개 대시보드만
|
||||
whereConditions.push("d.is_public = true");
|
||||
}
|
||||
|
||||
|
|
@ -228,7 +231,7 @@ export class DashboardService {
|
|||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
|
||||
// 대시보드 목록 조회 (users 테이블 조인 제거)
|
||||
// 대시보드 목록 조회 (user_info 조인하여 생성자 이름 포함)
|
||||
const dashboardQuery = `
|
||||
SELECT
|
||||
d.id,
|
||||
|
|
@ -242,13 +245,16 @@ export class DashboardService {
|
|||
d.tags,
|
||||
d.category,
|
||||
d.view_count,
|
||||
d.company_code,
|
||||
u.user_name as created_by_name,
|
||||
COUNT(de.id) as elements_count
|
||||
FROM dashboards d
|
||||
LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id
|
||||
LEFT JOIN user_info u ON d.created_by = u.user_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public,
|
||||
d.created_by, d.created_at, d.updated_at, d.tags, d.category,
|
||||
d.view_count
|
||||
d.view_count, d.company_code, u.user_name
|
||||
ORDER BY d.updated_at DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
|
@ -277,12 +283,14 @@ export class DashboardService {
|
|||
thumbnailUrl: row.thumbnail_url,
|
||||
isPublic: row.is_public,
|
||||
createdBy: row.created_by,
|
||||
createdByName: row.created_by_name || row.created_by,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
tags: JSON.parse(row.tags || "[]"),
|
||||
category: row.category,
|
||||
viewCount: parseInt(row.view_count || "0"),
|
||||
elementsCount: parseInt(row.elements_count || "0"),
|
||||
companyCode: row.company_code,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
|
|
@ -299,6 +307,8 @@ export class DashboardService {
|
|||
|
||||
/**
|
||||
* 대시보드 상세 조회
|
||||
* - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
|
||||
* - company_code가 '*'인 경우 최고 관리자만 조회 가능
|
||||
*/
|
||||
static async getDashboardById(
|
||||
dashboardId: string,
|
||||
|
|
@ -310,17 +320,27 @@ export class DashboardService {
|
|||
let dashboardQuery: string;
|
||||
let dashboardParams: any[];
|
||||
|
||||
if (userId) {
|
||||
if (companyCode) {
|
||||
// 회사 코드가 있으면 해당 회사 대시보드 또는 공개 대시보드 조회 가능
|
||||
// 최고 관리자(companyCode = '*')는 모든 대시보드 조회 가능
|
||||
if (companyCode === '*') {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
`;
|
||||
dashboardParams = [dashboardId];
|
||||
} else {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
AND (d.created_by = $3 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode, userId];
|
||||
} else {
|
||||
dashboardParams = [dashboardId, companyCode];
|
||||
}
|
||||
} else if (userId) {
|
||||
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
|
|
@ -328,18 +348,8 @@ export class DashboardService {
|
|||
AND (d.created_by = $2 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, userId];
|
||||
}
|
||||
} else {
|
||||
if (companyCode) {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
AND d.is_public = true
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode];
|
||||
} else {
|
||||
// 비로그인 사용자는 공개 대시보드만
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
|
|
@ -348,7 +358,6 @@ export class DashboardService {
|
|||
`;
|
||||
dashboardParams = [dashboardId];
|
||||
}
|
||||
}
|
||||
|
||||
const dashboardResult = await PostgreSQLService.query(
|
||||
dashboardQuery,
|
||||
|
|
|
|||
|
|
@ -342,4 +342,118 @@ export class AuthService {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 처리
|
||||
* - user_info 테이블에 사용자 정보 저장
|
||||
* - vehicles 테이블에 차량 정보 저장 (공차중계용)
|
||||
*/
|
||||
static async signupUser(data: {
|
||||
userId: string;
|
||||
password: string;
|
||||
userName: string;
|
||||
phoneNumber: string;
|
||||
licenseNumber: string;
|
||||
vehicleNumber: string;
|
||||
vehicleType?: string;
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = data;
|
||||
|
||||
// 1. 중복 사용자 확인
|
||||
const existingUser = await query<any>(
|
||||
`SELECT user_id FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "이미 존재하는 아이디입니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 중복 차량번호 확인
|
||||
const existingVehicle = await query<any>(
|
||||
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1`,
|
||||
[vehicleNumber]
|
||||
);
|
||||
|
||||
if (existingVehicle.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "이미 등록된 차량번호입니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 비밀번호 암호화
|
||||
const bcrypt = require("bcryptjs");
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// 4. 사용자 정보 저장 (user_info)
|
||||
await query(
|
||||
`INSERT INTO user_info (
|
||||
user_id,
|
||||
user_password,
|
||||
user_name,
|
||||
cell_phone,
|
||||
license_number,
|
||||
vehicle_number,
|
||||
company_code,
|
||||
user_type,
|
||||
status,
|
||||
regdate
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())`,
|
||||
[
|
||||
userId,
|
||||
hashedPassword,
|
||||
userName,
|
||||
phoneNumber,
|
||||
licenseNumber,
|
||||
vehicleNumber,
|
||||
"COMPANY_13", // 기본 회사 코드
|
||||
null, // user_type: null
|
||||
"active", // status: active
|
||||
]
|
||||
);
|
||||
|
||||
// 5. 차량 정보 저장 (vehicles) - 공차중계용
|
||||
// status = 'off': 앱 미사용/로그아웃 상태
|
||||
await query(
|
||||
`INSERT INTO vehicles (
|
||||
vehicle_number,
|
||||
vehicle_type,
|
||||
driver_name,
|
||||
driver_phone,
|
||||
status,
|
||||
company_code,
|
||||
user_id,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())`,
|
||||
[
|
||||
vehicleNumber,
|
||||
vehicleType || null,
|
||||
userName,
|
||||
phoneNumber,
|
||||
"off", // 초기 상태: off (앱 미사용)
|
||||
"COMPANY_13", // 기본 회사 코드
|
||||
userId, // 사용자 ID 연결
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(`회원가입 성공: ${userId}, 차량번호: ${vehicleNumber}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "회원가입이 완료되었습니다.",
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error("회원가입 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "회원가입 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,14 @@ export class BatchSchedulerService {
|
|||
try {
|
||||
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
|
||||
|
||||
// 매핑 정보가 없으면 상세 조회로 다시 가져오기
|
||||
if (!config.batch_mappings || config.batch_mappings.length === 0) {
|
||||
const fullConfig = await BatchService.getBatchConfigById(config.id);
|
||||
if (fullConfig.success && fullConfig.data) {
|
||||
config = fullConfig.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 실행 로그 생성
|
||||
const executionLogResponse =
|
||||
await BatchExecutionLogService.createExecutionLog({
|
||||
|
|
|
|||
|
|
@ -475,21 +475,17 @@ export class ExternalRestApiConnectionService {
|
|||
}
|
||||
|
||||
/**
|
||||
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
||||
* 인증 헤더 생성
|
||||
*/
|
||||
static async testConnection(
|
||||
testRequest: RestApiTestRequest,
|
||||
userCompanyCode?: string
|
||||
): Promise<RestApiTestResult> {
|
||||
const startTime = Date.now();
|
||||
static async getAuthHeaders(
|
||||
authType: AuthType,
|
||||
authConfig: any,
|
||||
companyCode?: string
|
||||
): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
// 헤더 구성
|
||||
const headers = { ...testRequest.headers };
|
||||
|
||||
// 인증 헤더 추가
|
||||
if (testRequest.auth_type === "db-token") {
|
||||
const cfg = testRequest.auth_config || {};
|
||||
if (authType === "db-token") {
|
||||
const cfg = authConfig || {};
|
||||
const {
|
||||
dbTableName,
|
||||
dbValueColumn,
|
||||
|
|
@ -503,13 +499,15 @@ export class ExternalRestApiConnectionService {
|
|||
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
if (!userCompanyCode) {
|
||||
if (!companyCode) {
|
||||
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
|
||||
}
|
||||
|
||||
const hasWhereColumn = !!dbWhereColumn;
|
||||
const hasWhereValue =
|
||||
dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== "";
|
||||
dbWhereValue !== undefined &&
|
||||
dbWhereValue !== null &&
|
||||
dbWhereValue !== "";
|
||||
|
||||
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
|
||||
if (hasWhereColumn !== hasWhereValue) {
|
||||
|
|
@ -536,7 +534,7 @@ export class ExternalRestApiConnectionService {
|
|||
WHERE company_code = $1
|
||||
`;
|
||||
|
||||
const params: any[] = [userCompanyCode];
|
||||
const params: any[] = [companyCode];
|
||||
|
||||
if (hasWhereColumn && hasWhereValue) {
|
||||
sql += ` AND ${dbWhereColumn} = $2`;
|
||||
|
|
@ -559,26 +557,43 @@ export class ExternalRestApiConnectionService {
|
|||
const template = dbHeaderTemplate || "Bearer {{value}}";
|
||||
|
||||
headers[headerName] = template.replace("{{value}}", tokenValue);
|
||||
} else if (
|
||||
testRequest.auth_type === "bearer" &&
|
||||
testRequest.auth_config?.token
|
||||
) {
|
||||
headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`;
|
||||
} else if (testRequest.auth_type === "basic" && testRequest.auth_config) {
|
||||
} else if (authType === "bearer" && authConfig?.token) {
|
||||
headers["Authorization"] = `Bearer ${authConfig.token}`;
|
||||
} else if (authType === "basic" && authConfig) {
|
||||
const credentials = Buffer.from(
|
||||
`${testRequest.auth_config.username}:${testRequest.auth_config.password}`
|
||||
`${authConfig.username}:${authConfig.password}`
|
||||
).toString("base64");
|
||||
headers["Authorization"] = `Basic ${credentials}`;
|
||||
} else if (
|
||||
testRequest.auth_type === "api-key" &&
|
||||
testRequest.auth_config
|
||||
) {
|
||||
if (testRequest.auth_config.keyLocation === "header") {
|
||||
headers[testRequest.auth_config.keyName] =
|
||||
testRequest.auth_config.keyValue;
|
||||
} else if (authType === "api-key" && authConfig) {
|
||||
if (authConfig.keyLocation === "header") {
|
||||
headers[authConfig.keyName] = authConfig.keyValue;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
||||
*/
|
||||
static async testConnection(
|
||||
testRequest: RestApiTestRequest,
|
||||
userCompanyCode?: string
|
||||
): Promise<RestApiTestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 헤더 구성
|
||||
let headers = { ...testRequest.headers };
|
||||
|
||||
// 인증 헤더 생성 및 병합
|
||||
const authHeaders = await this.getAuthHeaders(
|
||||
testRequest.auth_type,
|
||||
testRequest.auth_config,
|
||||
userCompanyCode
|
||||
);
|
||||
headers = { ...headers, ...authHeaders };
|
||||
|
||||
// URL 구성
|
||||
let url = testRequest.base_url;
|
||||
if (testRequest.endpoint) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { useSignup } from "@/hooks/useSignup";
|
||||
import { LoginHeader } from "@/components/auth/LoginHeader";
|
||||
import { SignupForm } from "@/components/auth/SignupForm";
|
||||
import { LoginFooter } from "@/components/auth/LoginFooter";
|
||||
|
||||
/**
|
||||
* 회원가입 페이지 컴포넌트
|
||||
*/
|
||||
export default function SignupPage() {
|
||||
const {
|
||||
formData,
|
||||
isLoading,
|
||||
error,
|
||||
showPassword,
|
||||
validationErrors,
|
||||
touchedFields,
|
||||
isFormValid,
|
||||
handleInputChange,
|
||||
handleBlur,
|
||||
handleSignup,
|
||||
togglePasswordVisibility,
|
||||
} = useSignup();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<LoginHeader />
|
||||
|
||||
<SignupForm
|
||||
formData={formData}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
showPassword={showPassword}
|
||||
validationErrors={validationErrors}
|
||||
touchedFields={touchedFields}
|
||||
isFormValid={isFormValid}
|
||||
onInputChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
onSubmit={handleSignup}
|
||||
onTogglePassword={togglePasswordVisibility}
|
||||
/>
|
||||
|
||||
<LoginFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -195,6 +195,7 @@ export default function DashboardListClient() {
|
|||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||
|
|
@ -209,6 +210,9 @@ export default function DashboardListClient() {
|
|||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
|
|
@ -277,6 +281,7 @@ export default function DashboardListClient() {
|
|||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||
|
|
@ -296,6 +301,9 @@ export default function DashboardListClient() {
|
|||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||
{dashboard.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{dashboard.createdByName || dashboard.createdBy || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.createdAt)}
|
||||
</TableCell>
|
||||
|
|
@ -363,6 +371,10 @@ export default function DashboardListClient() {
|
|||
<span className="text-muted-foreground">설명</span>
|
||||
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">생성자</span>
|
||||
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">생성일</span>
|
||||
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
|
|
@ -20,7 +21,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string>("");
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string>(dataSource.externalConnectionId || "");
|
||||
const [availableColumns, setAvailableColumns] = useState<string[]>([]); // API 테스트 후 발견된 컬럼 목록
|
||||
const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보
|
||||
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
|
||||
|
|
@ -35,6 +36,13 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
loadApiConnections();
|
||||
}, []);
|
||||
|
||||
// dataSource.externalConnectionId가 변경되면 selectedConnectionId 업데이트
|
||||
useEffect(() => {
|
||||
if (dataSource.externalConnectionId) {
|
||||
setSelectedConnectionId(dataSource.externalConnectionId);
|
||||
}
|
||||
}, [dataSource.externalConnectionId]);
|
||||
|
||||
// 외부 커넥션 선택 핸들러
|
||||
const handleConnectionSelect = async (connectionId: string) => {
|
||||
setSelectedConnectionId(connectionId);
|
||||
|
|
@ -58,11 +66,20 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
|
||||
const updates: Partial<ChartDataSource> = {
|
||||
endpoint: fullEndpoint,
|
||||
externalConnectionId: connectionId, // 외부 연결 ID 저장
|
||||
};
|
||||
|
||||
const headers: KeyValuePair[] = [];
|
||||
const queryParams: KeyValuePair[] = [];
|
||||
|
||||
// 기본 메서드/바디가 있으면 적용
|
||||
if (connection.default_method) {
|
||||
updates.method = connection.default_method as ChartDataSource["method"];
|
||||
}
|
||||
if (connection.default_body) {
|
||||
updates.body = connection.default_body;
|
||||
}
|
||||
|
||||
// 기본 헤더가 있으면 적용
|
||||
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
|
||||
Object.entries(connection.default_headers).forEach(([key, value]) => {
|
||||
|
|
@ -210,6 +227,11 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
}
|
||||
});
|
||||
|
||||
const bodyPayload =
|
||||
dataSource.body && dataSource.body.trim().length > 0
|
||||
? dataSource.body
|
||||
: undefined;
|
||||
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -219,6 +241,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
method: dataSource.method || "GET",
|
||||
headers,
|
||||
queryParams,
|
||||
body: bodyPayload,
|
||||
externalConnectionId: dataSource.externalConnectionId, // 외부 연결 ID 전달
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -415,6 +439,58 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* HTTP 메서드 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">HTTP 메서드</Label>
|
||||
<Select
|
||||
value={dataSource.method || "GET"}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
method: value as ChartDataSource["method"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET" className="text-xs">
|
||||
GET
|
||||
</SelectItem>
|
||||
<SelectItem value="POST" className="text-xs">
|
||||
POST
|
||||
</SelectItem>
|
||||
<SelectItem value="PUT" className="text-xs">
|
||||
PUT
|
||||
</SelectItem>
|
||||
<SelectItem value="DELETE" className="text-xs">
|
||||
DELETE
|
||||
</SelectItem>
|
||||
<SelectItem value="PATCH" className="text-xs">
|
||||
PATCH
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Request Body (POST/PUT/PATCH 일 때만) */}
|
||||
{(dataSource.method === "POST" ||
|
||||
dataSource.method === "PUT" ||
|
||||
dataSource.method === "PATCH") && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Request Body (선택)</Label>
|
||||
<Textarea
|
||||
value={dataSource.body || ""}
|
||||
onChange={(e) => onChange({ body: e.target.value })}
|
||||
placeholder='{"key": "value"} 또는 원시 페이로드를 그대로 입력하세요'
|
||||
className="h-24 text-xs font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
이 내용은 그대로 외부 API 요청 Body로 전송됩니다. JSON이 아니어도 됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON Path */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`jsonPath-\${dataSource.id}`} className="text-xs">
|
||||
|
|
|
|||
|
|
@ -149,7 +149,10 @@ export interface ChartDataSource {
|
|||
|
||||
// API 관련
|
||||
endpoint?: string; // API URL
|
||||
method?: "GET"; // HTTP 메서드 (GET만 지원)
|
||||
// HTTP 메서드 (기본 GET, POST/PUT/DELETE/PATCH도 지원)
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
// 요청 Body (옵션) - 문자열 그대로 전송 (JSON 또는 일반 텍스트)
|
||||
body?: string;
|
||||
headers?: KeyValuePair[]; // 커스텀 헤더 (배열)
|
||||
queryParams?: KeyValuePair[]; // URL 쿼리 파라미터 (배열)
|
||||
jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { LoginFormData } from "@/types/auth";
|
||||
import { ErrorMessage } from "./ErrorMessage";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface LoginFormProps {
|
||||
formData: LoginFormData;
|
||||
|
|
@ -28,6 +29,8 @@ export function LoginForm({
|
|||
onSubmit,
|
||||
onTogglePassword,
|
||||
}: LoginFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-xl">
|
||||
<CardHeader className="space-y-1">
|
||||
|
|
@ -97,6 +100,17 @@ export function LoginForm({
|
|||
"로그인"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 회원가입 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11 w-full font-medium"
|
||||
onClick={() => router.push("/signup")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
회원가입
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,263 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Eye, EyeOff, Loader2, ArrowLeft } from "lucide-react";
|
||||
import { SignupFormData } from "@/types/auth";
|
||||
import { ErrorMessage } from "./ErrorMessage";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface SignupFormProps {
|
||||
formData: SignupFormData;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
showPassword: boolean;
|
||||
validationErrors: Record<string, string>;
|
||||
touchedFields: Record<string, boolean>;
|
||||
isFormValid: boolean;
|
||||
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onBlur: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onTogglePassword: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 폼 컴포넌트
|
||||
*/
|
||||
export function SignupForm({
|
||||
formData,
|
||||
isLoading,
|
||||
error,
|
||||
showPassword,
|
||||
validationErrors,
|
||||
touchedFields,
|
||||
isFormValid,
|
||||
onInputChange,
|
||||
onBlur,
|
||||
onSubmit,
|
||||
onTogglePassword,
|
||||
}: SignupFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-center text-2xl">회원가입</CardTitle>
|
||||
<CardDescription className="text-center">새로운 계정을 만들어보세요</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ErrorMessage message={error} />
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{/* 아이디 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userId">아이디 *</Label>
|
||||
<Input
|
||||
id="userId"
|
||||
name="userId"
|
||||
type="text"
|
||||
placeholder="아이디를 입력하세요"
|
||||
value={formData.userId}
|
||||
onChange={onInputChange}
|
||||
onBlur={onBlur}
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
required
|
||||
/>
|
||||
{touchedFields?.userId && validationErrors.userId && (
|
||||
<p className="text-destructive text-xs">{validationErrors.userId}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">비밀번호 *</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
value={formData.password}
|
||||
onChange={onInputChange}
|
||||
onBlur={onBlur}
|
||||
disabled={isLoading}
|
||||
className="h-11 pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePassword}
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 transform cursor-pointer text-slate-400 transition-colors hover:text-slate-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{touchedFields?.password && validationErrors.password && (
|
||||
<p className="text-destructive text-xs">{validationErrors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 확인 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="passwordConfirm">비밀번호 확인 *</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="passwordConfirm"
|
||||
name="passwordConfirm"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="비밀번호를 다시 입력하세요"
|
||||
value={formData.passwordConfirm}
|
||||
onChange={onInputChange}
|
||||
onBlur={onBlur}
|
||||
disabled={isLoading}
|
||||
className="h-11 pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePassword}
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 transform cursor-pointer text-slate-400 transition-colors hover:text-slate-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{touchedFields?.passwordConfirm && validationErrors.passwordConfirm && (
|
||||
<p className="text-destructive text-xs">{validationErrors.passwordConfirm}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이름 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userName">이름 *</Label>
|
||||
<Input
|
||||
id="userName"
|
||||
name="userName"
|
||||
type="text"
|
||||
placeholder="이름을 입력하세요"
|
||||
value={formData.userName}
|
||||
onChange={onInputChange}
|
||||
onBlur={onBlur}
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
required
|
||||
/>
|
||||
{touchedFields?.userName && validationErrors.userName && (
|
||||
<p className="text-destructive text-xs">{validationErrors.userName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 연락처 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phoneNumber">연락처 *</Label>
|
||||
<Input
|
||||
id="phoneNumber"
|
||||
name="phoneNumber"
|
||||
type="text"
|
||||
placeholder="010-1234-5678"
|
||||
value={formData.phoneNumber}
|
||||
onChange={onInputChange}
|
||||
onBlur={onBlur}
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
required
|
||||
/>
|
||||
{touchedFields?.phoneNumber && validationErrors.phoneNumber && (
|
||||
<p className="text-destructive text-xs">{validationErrors.phoneNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 면허번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="licenseNumber">면허번호 *</Label>
|
||||
<Input
|
||||
id="licenseNumber"
|
||||
name="licenseNumber"
|
||||
type="text"
|
||||
placeholder="12-34-567890-12"
|
||||
value={formData.licenseNumber}
|
||||
onChange={onInputChange}
|
||||
onBlur={onBlur}
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
required
|
||||
/>
|
||||
{touchedFields?.licenseNumber && validationErrors.licenseNumber && (
|
||||
<p className="text-destructive text-xs">{validationErrors.licenseNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 차량번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vehicleNumber">차량번호 *</Label>
|
||||
<Input
|
||||
id="vehicleNumber"
|
||||
name="vehicleNumber"
|
||||
type="text"
|
||||
placeholder="12가1234"
|
||||
value={formData.vehicleNumber}
|
||||
onChange={onInputChange}
|
||||
onBlur={onBlur}
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
required
|
||||
/>
|
||||
{touchedFields?.vehicleNumber && validationErrors.vehicleNumber && (
|
||||
<p className="text-destructive text-xs">{validationErrors.vehicleNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 차량 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vehicleType">차량 타입 *</Label>
|
||||
<Input
|
||||
id="vehicleType"
|
||||
name="vehicleType"
|
||||
type="text"
|
||||
placeholder="예: 1톤, 5톤, 11톤, 25톤"
|
||||
value={formData.vehicleType}
|
||||
onChange={onInputChange}
|
||||
onBlur={onBlur}
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
required
|
||||
/>
|
||||
{touchedFields?.vehicleType && validationErrors.vehicleType && (
|
||||
<p className="text-destructive text-xs">{validationErrors.vehicleType}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 회원가입 버튼 */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-11 w-full bg-slate-900 font-medium text-white hover:bg-slate-800"
|
||||
disabled={isLoading || !isFormValid}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
가입 중...
|
||||
</>
|
||||
) : (
|
||||
"회원가입(공차중계)"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 로그인으로 돌아가기 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11 w-full font-medium"
|
||||
onClick={() => router.push("/login")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
로그인으로 돌아가기
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -64,7 +64,10 @@ export const useLogin = () => {
|
|||
// 로컬 스토리지에서 토큰 가져오기
|
||||
const token = localStorage.getItem("authToken");
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
// API URL 동적 계산 (매번 호출 시마다)
|
||||
const apiBaseUrl = API_BASE_URL;
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}${endpoint}`, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SignupFormData } from "@/types/auth";
|
||||
import { signupUser } from "@/lib/api/auth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
/**
|
||||
* 유효성 검사 함수들
|
||||
*/
|
||||
const validators = {
|
||||
// 연락처: 010-1234-5678 형식
|
||||
phoneNumber: (value: string): string | null => {
|
||||
const phoneRegex = /^01[0-9]-\d{3,4}-\d{4}$/;
|
||||
if (!value) return "연락처를 입력해주세요";
|
||||
if (!phoneRegex.test(value)) return "올바른 연락처 형식이 아닙니다 (예: 010-1234-5678)";
|
||||
return null;
|
||||
},
|
||||
|
||||
// 면허번호: 12-34-567890-12 형식
|
||||
licenseNumber: (value: string): string | null => {
|
||||
const licenseRegex = /^\d{2}-\d{2}-\d{6}-\d{2}$/;
|
||||
if (!value) return "면허번호를 입력해주세요";
|
||||
if (!licenseRegex.test(value)) return "올바른 면허번호 형식이 아닙니다 (예: 12-34-567890-12)";
|
||||
return null;
|
||||
},
|
||||
|
||||
// 차량번호: 12가1234 형식
|
||||
vehicleNumber: (value: string): string | null => {
|
||||
const vehicleRegex = /^\d{2,3}[가-힣]\d{4}$/;
|
||||
if (!value) return "차량번호를 입력해주세요";
|
||||
if (!vehicleRegex.test(value)) return "올바른 차량번호 형식이 아닙니다 (예: 12가1234)";
|
||||
return null;
|
||||
},
|
||||
|
||||
// 차량 타입: 필수 입력
|
||||
vehicleType: (value: string): string | null => {
|
||||
if (!value) return "차량 타입을 입력해주세요";
|
||||
if (value.length < 2) return "차량 타입은 2자 이상이어야 합니다";
|
||||
return null;
|
||||
},
|
||||
|
||||
// 아이디: 4자 이상
|
||||
userId: (value: string): string | null => {
|
||||
if (!value) return "아이디를 입력해주세요";
|
||||
if (value.length < 4) return "아이디는 4자 이상이어야 합니다";
|
||||
return null;
|
||||
},
|
||||
|
||||
// 비밀번호: 6자 이상
|
||||
password: (value: string): string | null => {
|
||||
if (!value) return "비밀번호를 입력해주세요";
|
||||
if (value.length < 6) return "비밀번호는 6자 이상이어야 합니다";
|
||||
return null;
|
||||
},
|
||||
|
||||
// 비밀번호 확인: password와 일치해야 함
|
||||
passwordConfirm: (value: string, password?: string): string | null => {
|
||||
if (!value) return "비밀번호 확인을 입력해주세요";
|
||||
if (password && value !== password) return "비밀번호가 일치하지 않습니다";
|
||||
return null;
|
||||
},
|
||||
|
||||
// 이름: 2자 이상
|
||||
userName: (value: string): string | null => {
|
||||
if (!value) return "이름을 입력해주세요";
|
||||
if (value.length < 2) return "이름은 2자 이상이어야 합니다";
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 회원가입 커스텀 훅
|
||||
*/
|
||||
export function useSignup() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState<SignupFormData>({
|
||||
userId: "",
|
||||
password: "",
|
||||
passwordConfirm: "",
|
||||
userName: "",
|
||||
phoneNumber: "",
|
||||
licenseNumber: "",
|
||||
vehicleNumber: "",
|
||||
vehicleType: "",
|
||||
});
|
||||
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const [touchedFields, setTouchedFields] = useState<Record<string, boolean>>({});
|
||||
const [isFormValid, setIsFormValid] = useState(false);
|
||||
|
||||
// 유효성 검사 실행
|
||||
const validateField = useCallback((name: string, value: string, password?: string) => {
|
||||
const validator = validators[name as keyof typeof validators];
|
||||
if (validator) {
|
||||
// passwordConfirm 검증 시 password도 함께 전달
|
||||
if (name === "passwordConfirm") {
|
||||
return validator(value, password);
|
||||
}
|
||||
return validator(value);
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// 전체 폼 유효성 검사
|
||||
const validateForm = useCallback(() => {
|
||||
const errors: Record<string, string> = {};
|
||||
let hasErrors = false;
|
||||
|
||||
Object.keys(formData).forEach((key) => {
|
||||
const error = validateField(key, formData[key as keyof SignupFormData], formData.password);
|
||||
if (error) {
|
||||
errors[key] = error;
|
||||
hasErrors = true;
|
||||
}
|
||||
});
|
||||
|
||||
setValidationErrors(errors);
|
||||
setIsFormValid(!hasErrors && Object.values(formData).every((value) => value !== ""));
|
||||
}, [formData, validateField]);
|
||||
|
||||
// 입력 변경 핸들러
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
const newFormData = { ...formData, [name]: value };
|
||||
setFormData(newFormData);
|
||||
|
||||
// touched된 필드만 실시간 유효성 검사
|
||||
if (touchedFields[name]) {
|
||||
const fieldError = validateField(name, value, newFormData.password);
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
[name]: fieldError || "",
|
||||
}));
|
||||
}
|
||||
|
||||
// password가 변경되면 passwordConfirm도 다시 검증
|
||||
if (name === "password" && touchedFields.passwordConfirm) {
|
||||
const confirmError = validateField("passwordConfirm", newFormData.passwordConfirm, value);
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
passwordConfirm: confirmError || "",
|
||||
}));
|
||||
}
|
||||
|
||||
setError("");
|
||||
},
|
||||
[validateField, touchedFields, formData],
|
||||
);
|
||||
|
||||
// 포커스 아웃 핸들러 (Blur)
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
// 필드를 touched로 표시
|
||||
setTouchedFields((prev) => ({ ...prev, [name]: true }));
|
||||
|
||||
// 유효성 검사 실행
|
||||
const fieldError = validateField(name, value, formData.password);
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
[name]: fieldError || "",
|
||||
}));
|
||||
},
|
||||
[validateField, formData.password],
|
||||
);
|
||||
|
||||
// 회원가입 제출
|
||||
const handleSignup = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// 모든 필드를 touched로 표시 (제출 시 모든 에러 표시)
|
||||
const allTouched = Object.keys(formData).reduce((acc, key) => ({ ...acc, [key]: true }), {});
|
||||
setTouchedFields(allTouched);
|
||||
|
||||
// 최종 유효성 검사
|
||||
validateForm();
|
||||
|
||||
if (!isFormValid) {
|
||||
setError("입력 정보를 다시 확인해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await signupUser(formData);
|
||||
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "회원가입 성공",
|
||||
description: "로그인 페이지로 이동합니다",
|
||||
});
|
||||
|
||||
// 1초 후 로그인 페이지로 이동
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 1000);
|
||||
} else {
|
||||
setError(response.message || "회원가입에 실패했습니다");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("회원가입 오류:", err);
|
||||
setError(err.message || "회원가입 중 오류가 발생했습니다");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[formData, isFormValid, router, toast, validateForm],
|
||||
);
|
||||
|
||||
// 비밀번호 표시 토글
|
||||
const togglePasswordVisibility = useCallback(() => {
|
||||
setShowPassword((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// 폼 데이터 변경 시 유효성 검사 실행
|
||||
useEffect(() => {
|
||||
validateForm();
|
||||
}, [formData, validateForm]);
|
||||
|
||||
return {
|
||||
formData,
|
||||
isLoading,
|
||||
error,
|
||||
showPassword,
|
||||
validationErrors,
|
||||
touchedFields,
|
||||
isFormValid,
|
||||
handleInputChange,
|
||||
handleBlur,
|
||||
handleSignup,
|
||||
togglePasswordVisibility,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { apiClient } from "./client";
|
||||
import { SignupFormData, SignupResponse } from "@/types/auth";
|
||||
|
||||
/**
|
||||
* 회원가입 API
|
||||
*/
|
||||
export async function signupUser(data: SignupFormData): Promise<SignupResponse> {
|
||||
try {
|
||||
const response = await apiClient.post<SignupResponse>("/auth/signup", data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.data) {
|
||||
return error.response.data;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "회원가입 중 오류가 발생했습니다",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -13,6 +13,11 @@ const getApiBaseUrl = (): string => {
|
|||
const currentPort = window.location.port;
|
||||
const protocol = window.location.protocol;
|
||||
|
||||
// 프로덕션 환경: logistream.kpslp.kr → Ingress를 통한 접근 (포트 없음)
|
||||
if (currentHost === "logistream.kpslp.kr") {
|
||||
return `${protocol}//${currentHost}/api`;
|
||||
}
|
||||
|
||||
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
|
||||
if (currentHost === "v1.vexplor.com") {
|
||||
return "https://api.vexplor.com/api";
|
||||
|
|
@ -27,11 +32,21 @@ const getApiBaseUrl = (): string => {
|
|||
}
|
||||
}
|
||||
|
||||
// 3. 기본값
|
||||
return "http://localhost:8080/api";
|
||||
// 3. 기본값 (서버사이드 빌드 시)
|
||||
return process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api";
|
||||
};
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl();
|
||||
// 매번 호출 시 동적으로 계산 (getter 함수)
|
||||
export const getAPIBaseURL = getApiBaseUrl;
|
||||
|
||||
// 하위 호환성을 위해 유지하되, 동적으로 계산되도록 수정
|
||||
let _cachedApiBaseUrl: string | null = null;
|
||||
export const API_BASE_URL = (() => {
|
||||
if (_cachedApiBaseUrl === null || typeof window !== "undefined") {
|
||||
_cachedApiBaseUrl = getApiBaseUrl();
|
||||
}
|
||||
return _cachedApiBaseUrl;
|
||||
})();
|
||||
|
||||
// 이미지 URL을 완전한 URL로 변환하는 함수
|
||||
export const getFullImageUrl = (imagePath: string): string => {
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export interface Dashboard {
|
|||
thumbnailUrl?: string;
|
||||
isPublic: boolean;
|
||||
createdBy: string;
|
||||
createdByName?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags?: string[];
|
||||
|
|
@ -97,6 +98,7 @@ export interface Dashboard {
|
|||
viewCount: number;
|
||||
elementsCount?: number;
|
||||
creatorName?: string;
|
||||
companyCode?: string;
|
||||
elements?: DashboardElement[];
|
||||
settings?: {
|
||||
resolution?: string;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ export interface ExternalApiConnection {
|
|||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
// 기본 HTTP 메서드/바디 (외부 REST API 커넥션과 동일한 필드)
|
||||
default_method?: string;
|
||||
default_body?: string;
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
keyLocation?: "header" | "query";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
|
||||
import { cn } from "@/lib/registry/components/common/inputStyles";
|
||||
|
|
@ -65,6 +66,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
});
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// 드롭다운 위치 (Portal 렌더링용)
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
|
||||
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
||||
const config = (props as any).webTypeConfig || componentConfig || {};
|
||||
|
|
@ -326,9 +329,26 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
}, [selectedValue, codeOptions, config.options]);
|
||||
|
||||
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
||||
// 드롭다운 위치 계산 함수
|
||||
const updateDropdownPosition = () => {
|
||||
if (selectRef.current) {
|
||||
const rect = selectRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX,
|
||||
width: rect.width,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isDesignMode) return;
|
||||
|
||||
// 드롭다운 열기 전에 위치 계산
|
||||
if (!isOpen) {
|
||||
updateDropdownPosition();
|
||||
}
|
||||
|
||||
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
|
@ -450,9 +470,13 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
value={searchQuery || selectedLabel}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
updateDropdownPosition();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
updateDropdownPosition();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder="코드 또는 코드명 입력..."
|
||||
className={cn(
|
||||
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
||||
|
|
@ -461,8 +485,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
)}
|
||||
readOnly={isDesignMode}
|
||||
/>
|
||||
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
|
||||
{isOpen && !isDesignMode && filteredOptions.length > 0 && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
width: dropdownPosition.width,
|
||||
}}
|
||||
>
|
||||
{filteredOptions.map((option, index) => (
|
||||
<div
|
||||
key={`${option.value}-${index}`}
|
||||
|
|
@ -478,7 +510,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -508,8 +541,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && !isDesignMode && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
|
||||
{isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
width: dropdownPosition.width,
|
||||
}}
|
||||
>
|
||||
{isLoadingCodes ? (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||
) : allOptions.length > 0 ? (
|
||||
|
|
@ -525,7 +566,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
) : (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -590,9 +632,13 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
updateDropdownPosition();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
updateDropdownPosition();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
||||
|
|
@ -601,8 +647,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
)}
|
||||
readOnly={isDesignMode}
|
||||
/>
|
||||
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
|
||||
{isOpen && !isDesignMode && filteredOptions.length > 0 && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
width: dropdownPosition.width,
|
||||
}}
|
||||
>
|
||||
{filteredOptions.map((option, index) => (
|
||||
<div
|
||||
key={`${option.value}-${index}`}
|
||||
|
|
@ -620,7 +674,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -650,8 +705,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && !isDesignMode && (
|
||||
<div className="absolute z-[99999] mt-1 w-full rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
|
||||
{isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
className="fixed z-[99999] rounded-md border border-gray-300 bg-white shadow-lg"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
width: dropdownPosition.width,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
|
|
@ -676,7 +739,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -693,7 +757,12 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
!isDesignMode && "hover:border-orange-400",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
)}
|
||||
onClick={() => !isDesignMode && setIsOpen(true)}
|
||||
onClick={() => {
|
||||
if (!isDesignMode) {
|
||||
updateDropdownPosition();
|
||||
setIsOpen(true);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
pointerEvents: isDesignMode ? "none" : "auto",
|
||||
height: "100%"
|
||||
|
|
@ -726,22 +795,30 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<span className="text-gray-500">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && !isDesignMode && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
|
||||
{isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
width: dropdownPosition.width,
|
||||
}}
|
||||
>
|
||||
{(isLoadingCodes || isLoadingCategories) ? (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||
) : allOptions.length > 0 ? (
|
||||
allOptions.map((option, index) => {
|
||||
const isSelected = selectedValues.includes(option.value);
|
||||
const isOptionSelected = selectedValues.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={`${option.value}-${index}`}
|
||||
className={cn(
|
||||
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
||||
isSelected && "bg-blue-50 font-medium"
|
||||
isOptionSelected && "bg-blue-50 font-medium"
|
||||
)}
|
||||
onClick={() => {
|
||||
const newVals = isSelected
|
||||
const newVals = isOptionSelected
|
||||
? selectedValues.filter((v) => v !== option.value)
|
||||
: [...selectedValues, option.value];
|
||||
setSelectedValues(newVals);
|
||||
|
|
@ -754,7 +831,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
checked={isOptionSelected}
|
||||
onChange={() => {}}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
|
|
@ -766,7 +843,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
) : (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -795,8 +873,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && !isDesignMode && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
|
||||
{isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
width: dropdownPosition.width,
|
||||
}}
|
||||
>
|
||||
{isLoadingCodes ? (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||
) : allOptions.length > 0 ? (
|
||||
|
|
@ -812,7 +898,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
) : (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ export function getApiUrl(endpoint: string): string {
|
|||
if (typeof window !== "undefined") {
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// 프로덕션: logistream.kpslp.kr → Ingress를 통한 접근 (포트 없음)
|
||||
if (hostname === "logistream.kpslp.kr") {
|
||||
return `https://logistream.kpslp.kr${endpoint}`;
|
||||
}
|
||||
|
||||
// 프로덕션: v1.vexplor.com → https://api.vexplor.com
|
||||
if (hostname === "v1.vexplor.com") {
|
||||
return `https://api.vexplor.com${endpoint}`;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,17 @@ export interface LoginFormData {
|
|||
password: string;
|
||||
}
|
||||
|
||||
export interface SignupFormData {
|
||||
userId: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
userName: string;
|
||||
phoneNumber: string;
|
||||
licenseNumber: string;
|
||||
vehicleNumber: string;
|
||||
vehicleType: string; // 차량 타입 (예: 1톤, 5톤, 11톤)
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
|
|
@ -18,6 +29,14 @@ export interface LoginResponse {
|
|||
errorCode?: string;
|
||||
}
|
||||
|
||||
export interface SignupResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: {
|
||||
userId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
isLoggedIn: boolean;
|
||||
isAdmin?: boolean;
|
||||
|
|
|
|||
Loading…
Reference in New Issue