Compare commits
34 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
e6760329bc | |
|
|
2ae43c879f | |
|
|
b11f02aceb | |
|
|
166004e8fd | |
|
|
08d4d7dbfc | |
|
|
d7ee63a857 | |
|
|
c657d6f7a0 | |
|
|
b787b027a6 | |
|
|
c38153eff1 | |
|
|
0c897ad8fd | |
|
|
8e455def0c | |
|
|
10c16c818a | |
|
|
6cbe200f00 | |
|
|
660ddb0f95 | |
|
|
0b61ef4d12 | |
|
|
8f926f6887 | |
|
|
cb8184735c | |
|
|
ec26aa1bac | |
|
|
22b6404a5b | |
|
|
a2e58c3848 | |
|
|
ff21a84932 | |
|
|
91a4401120 | |
|
|
1514af2383 | |
|
|
c669374156 | |
|
|
cf2b5d4e80 | |
|
|
3153cf0383 | |
|
|
198f9a6f2b | |
|
|
b47f34c616 | |
|
|
8508e64ab3 | |
|
|
12b5c4243a | |
|
|
802cda7348 | |
|
|
70e97aa4a2 | |
|
|
f340b1ac05 | |
|
|
50dbf1f738 |
51
Dockerfile
51
Dockerfile
|
|
@ -39,8 +39,10 @@ RUN npm ci && \
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
|
|
||||||
# Next.js 프로덕션 빌드 (린트 비활성화)
|
# Next.js 프로덕션 빌드 (린트 비활성화)
|
||||||
|
# 빌드 시점에 환경변수 설정 (번들에 포함됨)
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api"
|
||||||
RUN npm run build:no-lint
|
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/public ./frontend/public
|
||||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs
|
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs
|
||||||
|
|
||||||
# 업로드 디렉토리 생성 (백엔드용)
|
# 백엔드 디렉토리 생성 (업로드, 로그, 데이터)
|
||||||
RUN mkdir -p /app/backend/uploads && \
|
# /app/uploads, /app/data 경로는 백엔드 코드에서 동적으로 하위 디렉토리 생성
|
||||||
chown -R nodejs:nodejs /app/backend/uploads
|
# 상위 디렉토리에 쓰기 권한 부여하여 런타임에 자유롭게 생성 가능하도록 함
|
||||||
|
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 && \
|
RUN echo '#!/bin/sh' > /app/start.sh && \
|
||||||
|
|
@ -77,29 +89,44 @@ RUN echo '#!/bin/sh' > /app/start.sh && \
|
||||||
echo '# 백엔드 시작 (백그라운드)' >> /app/start.sh && \
|
echo '# 백엔드 시작 (백그라운드)' >> /app/start.sh && \
|
||||||
echo 'cd /app/backend' >> /app/start.sh && \
|
echo 'cd /app/backend' >> /app/start.sh && \
|
||||||
echo 'echo "Starting backend on port 8080..."' >> /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 'BACKEND_PID=$!' >> /app/start.sh && \
|
||||||
echo '' >> /app/start.sh && \
|
echo '' >> /app/start.sh && \
|
||||||
echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \
|
echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \
|
||||||
echo 'cd /app/frontend' >> /app/start.sh && \
|
echo 'cd /app/frontend' >> /app/start.sh && \
|
||||||
echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \
|
echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \
|
||||||
echo 'npm start &' >> /app/start.sh && \
|
echo 'PORT=3000 exec 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 && \
|
|
||||||
chmod +x /app/start.sh && \
|
chmod +x /app/start.sh && \
|
||||||
chown nodejs:nodejs /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
|
USER nodejs
|
||||||
|
|
||||||
# 포트 노출
|
# 포트 노출
|
||||||
EXPOSE 3000 8080
|
EXPOSE 3000 8080
|
||||||
|
|
||||||
# 헬스체크
|
# 헬스체크 (백엔드와 프론트엔드 둘 다 확인)
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
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"]
|
CMD ["/app/start.sh"]
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"react-quill": "^2.0.0",
|
"react-quill": "^2.0.0",
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -10642,16 +10642,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "13.0.0",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist-node/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/v8-compile-cache-lib": {
|
"node_modules/v8-compile-cache-lib": {
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"react-quill": "^2.0.0",
|
"react-quill": "^2.0.0",
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -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 : "알 수 없는 오류가 발생했습니다.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,10 @@ router.post("/logout", AuthController.logout);
|
||||||
*/
|
*/
|
||||||
router.post("/refresh", AuthController.refreshToken);
|
router.post("/refresh", AuthController.refreshToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/signup
|
||||||
|
* 회원가입 API
|
||||||
|
*/
|
||||||
|
router.post("/signup", AuthController.signup);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,8 @@ export class AuthService {
|
||||||
[userInfo.company_code || "ILSHIN"]
|
[userInfo.company_code || "ILSHIN"]
|
||||||
);
|
);
|
||||||
|
|
||||||
const companyName = companyResult.length > 0 ? companyResult[0].company_name : undefined;
|
const companyName =
|
||||||
|
companyResult.length > 0 ? companyResult[0].company_name : undefined;
|
||||||
|
|
||||||
// DB에서 조회한 원본 사용자 정보 상세 로그
|
// DB에서 조회한 원본 사용자 정보 상세 로그
|
||||||
//console.log("🔍 AuthService - DB 원본 사용자 정보:", {
|
//console.log("🔍 AuthService - DB 원본 사용자 정보:", {
|
||||||
|
|
@ -342,4 +343,129 @@ 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. 비밀번호 암호화 (MD5 - 기존 시스템 호환)
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const hashedPassword = crypto
|
||||||
|
.createHash("md5")
|
||||||
|
.update(password)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
// 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 || "회원가입 중 오류가 발생했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||||
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
||||||
import { LoginFormData } from "@/types/auth";
|
import { LoginFormData } from "@/types/auth";
|
||||||
import { ErrorMessage } from "./ErrorMessage";
|
import { ErrorMessage } from "./ErrorMessage";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
formData: LoginFormData;
|
formData: LoginFormData;
|
||||||
|
|
@ -28,6 +29,8 @@ export function LoginForm({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onTogglePassword,
|
onTogglePassword,
|
||||||
}: LoginFormProps) {
|
}: LoginFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-xl">
|
<Card className="border-0 shadow-xl">
|
||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
|
|
@ -97,6 +100,17 @@ export function LoginForm({
|
||||||
"로그인"
|
"로그인"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* 회원가입 버튼 */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 w-full font-medium"
|
||||||
|
onClick={() => router.push("/signup")}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
회원가입
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 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",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"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 currentPort = window.location.port;
|
||||||
const protocol = window.location.protocol;
|
const protocol = window.location.protocol;
|
||||||
|
|
||||||
|
// 프로덕션 환경: logistream.kpslp.kr → Ingress를 통한 접근 (포트 없음)
|
||||||
|
if (currentHost === "logistream.kpslp.kr") {
|
||||||
|
return `${protocol}//${currentHost}/api`;
|
||||||
|
}
|
||||||
|
|
||||||
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
|
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
|
||||||
if (currentHost === "v1.vexplor.com") {
|
if (currentHost === "v1.vexplor.com") {
|
||||||
return "https://api.vexplor.com/api";
|
return "https://api.vexplor.com/api";
|
||||||
|
|
@ -27,11 +32,21 @@ const getApiBaseUrl = (): string => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 기본값
|
// 3. 기본값 (서버사이드 빌드 시)
|
||||||
return "http://localhost:8080/api";
|
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로 변환하는 함수
|
// 이미지 URL을 완전한 URL로 변환하는 함수
|
||||||
export const getFullImageUrl = (imagePath: string): string => {
|
export const getFullImageUrl = (imagePath: string): string => {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const {
|
const {
|
||||||
selectedScreen,
|
selectedScreen,
|
||||||
onZoneComponentDrop,
|
onZoneComponentDrop,
|
||||||
|
|
@ -70,8 +71,40 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
||||||
tableName: _tableName,
|
tableName: _tableName,
|
||||||
onRefresh: _onRefresh,
|
onRefresh: _onRefresh,
|
||||||
onClose: _onClose,
|
onClose: _onClose,
|
||||||
|
// 추가된 props 필터링
|
||||||
|
webType: _webType,
|
||||||
|
autoGeneration: _autoGeneration,
|
||||||
|
isInteractive: _isInteractive,
|
||||||
|
formData: _formData,
|
||||||
|
onFormDataChange: _onFormDataChange,
|
||||||
|
menuId: _menuId,
|
||||||
|
menuObjid: _menuObjid,
|
||||||
|
onSave: _onSave,
|
||||||
|
userId: _userId,
|
||||||
|
userName: _userName,
|
||||||
|
companyCode: _companyCode,
|
||||||
|
isInModal: _isInModal,
|
||||||
|
readonly: _readonly,
|
||||||
|
originalData: _originalData,
|
||||||
|
allComponents: _allComponents,
|
||||||
|
onUpdateLayout: _onUpdateLayout,
|
||||||
|
selectedRows: _selectedRows,
|
||||||
|
selectedRowsData: _selectedRowsData,
|
||||||
|
onSelectedRowsChange: _onSelectedRowsChange,
|
||||||
|
sortBy: _sortBy,
|
||||||
|
sortOrder: _sortOrder,
|
||||||
|
tableDisplayData: _tableDisplayData,
|
||||||
|
flowSelectedData: _flowSelectedData,
|
||||||
|
flowSelectedStepId: _flowSelectedStepId,
|
||||||
|
onFlowSelectedDataChange: _onFlowSelectedDataChange,
|
||||||
|
onConfigChange: _onConfigChange,
|
||||||
|
refreshKey: _refreshKey,
|
||||||
|
flowRefreshKey: _flowRefreshKey,
|
||||||
|
onFlowRefresh: _onFlowRefresh,
|
||||||
|
isPreview: _isPreview,
|
||||||
|
groupedData: _groupedData,
|
||||||
...domProps
|
...domProps
|
||||||
} = props;
|
} = props as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={componentStyle} className={className} {...domProps}>
|
<div style={componentStyle} className={className} {...domProps}>
|
||||||
|
|
|
||||||
|
|
@ -103,11 +103,36 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
const departureValue = formData[departureField] || "";
|
const departureValue = formData[departureField] || "";
|
||||||
const destinationValue = formData[destinationField] || "";
|
const destinationValue = formData[destinationField] || "";
|
||||||
|
|
||||||
|
// 기본 옵션 (포항/광양)
|
||||||
|
const DEFAULT_OPTIONS: LocationOption[] = [
|
||||||
|
{ value: "pohang", label: "포항" },
|
||||||
|
{ value: "gwangyang", label: "광양" },
|
||||||
|
];
|
||||||
|
|
||||||
// 옵션 로드
|
// 옵션 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadOptions = async () => {
|
const loadOptions = async () => {
|
||||||
if (dataSource.type === "static") {
|
console.log("[LocationSwapSelector] 옵션 로드 시작:", { dataSource, isDesignMode });
|
||||||
setOptions(dataSource.staticOptions || []);
|
|
||||||
|
// 정적 옵션 처리 (기본값)
|
||||||
|
// type이 없거나 static이거나, table인데 tableName이 없는 경우
|
||||||
|
const shouldUseStatic =
|
||||||
|
!dataSource.type ||
|
||||||
|
dataSource.type === "static" ||
|
||||||
|
(dataSource.type === "table" && !dataSource.tableName) ||
|
||||||
|
(dataSource.type === "code" && !dataSource.codeCategory);
|
||||||
|
|
||||||
|
if (shouldUseStatic) {
|
||||||
|
const staticOpts = dataSource.staticOptions || [];
|
||||||
|
// 정적 옵션이 설정되어 있으면 사용
|
||||||
|
if (staticOpts.length > 0 && staticOpts[0]?.value) {
|
||||||
|
console.log("[LocationSwapSelector] 정적 옵션 사용:", staticOpts);
|
||||||
|
setOptions(staticOpts);
|
||||||
|
} else {
|
||||||
|
// 기본값 (포항/광양)
|
||||||
|
console.log("[LocationSwapSelector] 기본 옵션 사용:", DEFAULT_OPTIONS);
|
||||||
|
setOptions(DEFAULT_OPTIONS);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,11 +140,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
// 코드 관리에서 가져오기
|
// 코드 관리에서 가져오기
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/api/codes/${dataSource.codeCategory}`);
|
const response = await apiClient.get(`/code-management/codes`, {
|
||||||
|
params: { categoryCode: dataSource.codeCategory },
|
||||||
|
});
|
||||||
if (response.data.success && response.data.data) {
|
if (response.data.success && response.data.data) {
|
||||||
const codeOptions = response.data.data.map((code: any) => ({
|
const codeOptions = response.data.data.map((code: any) => ({
|
||||||
value: code.code_value || code.codeValue,
|
value: code.code_value || code.codeValue || code.code,
|
||||||
label: code.code_name || code.codeName,
|
label: code.code_name || code.codeName || code.name,
|
||||||
}));
|
}));
|
||||||
setOptions(codeOptions);
|
setOptions(codeOptions);
|
||||||
}
|
}
|
||||||
|
|
@ -135,13 +162,17 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
// 테이블에서 가져오기
|
// 테이블에서 가져오기
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/api/dynamic/${dataSource.tableName}`, {
|
const response = await apiClient.get(`/dynamic-form/list/${dataSource.tableName}`, {
|
||||||
params: { pageSize: 1000 },
|
params: { page: 1, pageSize: 1000 },
|
||||||
});
|
});
|
||||||
if (response.data.success && response.data.data) {
|
if (response.data.success && response.data.data) {
|
||||||
const tableOptions = response.data.data.map((row: any) => ({
|
// data가 배열인지 또는 data.rows인지 확인
|
||||||
value: row[dataSource.valueField || "id"],
|
const rows = Array.isArray(response.data.data)
|
||||||
label: row[dataSource.labelField || "name"],
|
? response.data.data
|
||||||
|
: response.data.data.rows || [];
|
||||||
|
const tableOptions = rows.map((row: any) => ({
|
||||||
|
value: String(row[dataSource.valueField || "id"] || ""),
|
||||||
|
label: String(row[dataSource.labelField || "name"] || ""),
|
||||||
}));
|
}));
|
||||||
setOptions(tableOptions);
|
setOptions(tableOptions);
|
||||||
}
|
}
|
||||||
|
|
@ -153,17 +184,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isDesignMode) {
|
loadOptions();
|
||||||
loadOptions();
|
|
||||||
} else {
|
|
||||||
// 디자인 모드에서는 샘플 데이터
|
|
||||||
setOptions([
|
|
||||||
{ value: "seoul", label: "서울" },
|
|
||||||
{ value: "busan", label: "부산" },
|
|
||||||
{ value: "pohang", label: "포항" },
|
|
||||||
{ value: "gwangyang", label: "광양" },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}, [dataSource, isDesignMode]);
|
}, [dataSource, isDesignMode]);
|
||||||
|
|
||||||
// 출발지 변경
|
// 출발지 변경
|
||||||
|
|
@ -244,7 +265,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
<Select
|
<Select
|
||||||
value={departureValue}
|
value={departureValue}
|
||||||
onValueChange={handleDepartureChange}
|
onValueChange={handleDepartureChange}
|
||||||
disabled={loading || isDesignMode}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0">
|
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0">
|
||||||
<SelectValue placeholder="선택">
|
<SelectValue placeholder="선택">
|
||||||
|
|
@ -253,12 +274,16 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
</span>
|
</span>
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent position="popper" sideOffset={4}>
|
||||||
{options.map((option) => (
|
{options.length > 0 ? (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
options.map((option) => (
|
||||||
{option.label}
|
<SelectItem key={option.value} value={option.value}>
|
||||||
</SelectItem>
|
{option.label}
|
||||||
))}
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-2 py-1.5 text-sm text-muted-foreground">옵션 없음</div>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -270,7 +295,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleSwap}
|
onClick={handleSwap}
|
||||||
disabled={isDesignMode || !departureValue || !destinationValue}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted",
|
"mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted",
|
||||||
isSwapping && "rotate-180"
|
isSwapping && "rotate-180"
|
||||||
|
|
@ -286,7 +310,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
<Select
|
<Select
|
||||||
value={destinationValue}
|
value={destinationValue}
|
||||||
onValueChange={handleDestinationChange}
|
onValueChange={handleDestinationChange}
|
||||||
disabled={loading || isDesignMode}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0">
|
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0">
|
||||||
<SelectValue placeholder="선택">
|
<SelectValue placeholder="선택">
|
||||||
|
|
@ -295,12 +319,16 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
</span>
|
</span>
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent position="popper" sideOffset={4}>
|
||||||
{options.map((option) => (
|
{options.length > 0 ? (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
options.map((option) => (
|
||||||
{option.label}
|
<SelectItem key={option.value} value={option.value}>
|
||||||
</SelectItem>
|
{option.label}
|
||||||
))}
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-2 py-1.5 text-sm text-muted-foreground">옵션 없음</div>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -322,17 +350,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
<Select
|
<Select
|
||||||
value={departureValue}
|
value={departureValue}
|
||||||
onValueChange={handleDepartureChange}
|
onValueChange={handleDepartureChange}
|
||||||
disabled={loading || isDesignMode}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-10">
|
<SelectTrigger className="h-10">
|
||||||
<SelectValue placeholder="선택" />
|
<SelectValue placeholder="선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent position="popper" sideOffset={4}>
|
||||||
{options.map((option) => (
|
{options.length > 0 ? (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
options.map((option) => (
|
||||||
{option.label}
|
<SelectItem key={option.value} value={option.value}>
|
||||||
</SelectItem>
|
{option.label}
|
||||||
))}
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-2 py-1.5 text-sm text-muted-foreground">옵션 없음</div>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -343,7 +375,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleSwap}
|
onClick={handleSwap}
|
||||||
disabled={isDesignMode}
|
|
||||||
className="mt-5 h-10 w-10"
|
className="mt-5 h-10 w-10"
|
||||||
>
|
>
|
||||||
<ArrowLeftRight className="h-4 w-4" />
|
<ArrowLeftRight className="h-4 w-4" />
|
||||||
|
|
@ -355,17 +386,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
<Select
|
<Select
|
||||||
value={destinationValue}
|
value={destinationValue}
|
||||||
onValueChange={handleDestinationChange}
|
onValueChange={handleDestinationChange}
|
||||||
disabled={loading || isDesignMode}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-10">
|
<SelectTrigger className="h-10">
|
||||||
<SelectValue placeholder="선택" />
|
<SelectValue placeholder="선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent position="popper" sideOffset={4}>
|
||||||
{options.map((option) => (
|
{options.length > 0 ? (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
options.map((option) => (
|
||||||
{option.label}
|
<SelectItem key={option.value} value={option.value}>
|
||||||
</SelectItem>
|
{option.label}
|
||||||
))}
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-2 py-1.5 text-sm text-muted-foreground">옵션 없음</div>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -383,17 +418,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
<Select
|
<Select
|
||||||
value={departureValue}
|
value={departureValue}
|
||||||
onValueChange={handleDepartureChange}
|
onValueChange={handleDepartureChange}
|
||||||
disabled={loading || isDesignMode}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 flex-1 text-sm">
|
<SelectTrigger className="h-8 flex-1 text-sm">
|
||||||
<SelectValue placeholder={departureLabel} />
|
<SelectValue placeholder={departureLabel} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent position="popper" sideOffset={4}>
|
||||||
{options.map((option) => (
|
{options.length > 0 ? (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
options.map((option) => (
|
||||||
{option.label}
|
<SelectItem key={option.value} value={option.value}>
|
||||||
</SelectItem>
|
{option.label}
|
||||||
))}
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-2 py-1.5 text-sm text-muted-foreground">옵션 없음</div>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
|
@ -403,7 +442,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleSwap}
|
onClick={handleSwap}
|
||||||
disabled={isDesignMode}
|
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
<ArrowLeftRight className="h-4 w-4" />
|
<ArrowLeftRight className="h-4 w-4" />
|
||||||
|
|
@ -413,17 +451,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
<Select
|
<Select
|
||||||
value={destinationValue}
|
value={destinationValue}
|
||||||
onValueChange={handleDestinationChange}
|
onValueChange={handleDestinationChange}
|
||||||
disabled={loading || isDesignMode}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 flex-1 text-sm">
|
<SelectTrigger className="h-8 flex-1 text-sm">
|
||||||
<SelectValue placeholder={destinationLabel} />
|
<SelectValue placeholder={destinationLabel} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent position="popper" sideOffset={4}>
|
||||||
{options.map((option) => (
|
{options.length > 0 ? (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
options.map((option) => (
|
||||||
{option.label}
|
<SelectItem key={option.value} value={option.value}>
|
||||||
</SelectItem>
|
{option.label}
|
||||||
))}
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-2 py-1.5 text-sm text-muted-foreground">옵션 없음</div>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -139,13 +139,83 @@ export function LocationSwapSelectorConfigPanel({
|
||||||
<SelectValue placeholder="선택" />
|
<SelectValue placeholder="선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="static">정적 옵션 (하드코딩)</SelectItem>
|
<SelectItem value="static">고정 옵션 (포항/광양 등)</SelectItem>
|
||||||
<SelectItem value="table">테이블</SelectItem>
|
<SelectItem value="table">테이블에서 가져오기</SelectItem>
|
||||||
<SelectItem value="code">코드 관리</SelectItem>
|
<SelectItem value="code">코드 관리에서 가져오기</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 고정 옵션 설정 (type이 static일 때) */}
|
||||||
|
{(!config?.dataSource?.type || config?.dataSource?.type === "static") && (
|
||||||
|
<div className="space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
|
||||||
|
<h4 className="text-sm font-medium">고정 옵션 설정</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>옵션 1 (값)</Label>
|
||||||
|
<Input
|
||||||
|
value={config?.dataSource?.staticOptions?.[0]?.value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const options = config?.dataSource?.staticOptions || [];
|
||||||
|
const newOptions = [...options];
|
||||||
|
newOptions[0] = { ...newOptions[0], value: e.target.value };
|
||||||
|
handleChange("dataSource.staticOptions", newOptions);
|
||||||
|
}}
|
||||||
|
placeholder="예: pohang"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>옵션 1 (표시명)</Label>
|
||||||
|
<Input
|
||||||
|
value={config?.dataSource?.staticOptions?.[0]?.label || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const options = config?.dataSource?.staticOptions || [];
|
||||||
|
const newOptions = [...options];
|
||||||
|
newOptions[0] = { ...newOptions[0], label: e.target.value };
|
||||||
|
handleChange("dataSource.staticOptions", newOptions);
|
||||||
|
}}
|
||||||
|
placeholder="예: 포항"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>옵션 2 (값)</Label>
|
||||||
|
<Input
|
||||||
|
value={config?.dataSource?.staticOptions?.[1]?.value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const options = config?.dataSource?.staticOptions || [];
|
||||||
|
const newOptions = [...options];
|
||||||
|
newOptions[1] = { ...newOptions[1], value: e.target.value };
|
||||||
|
handleChange("dataSource.staticOptions", newOptions);
|
||||||
|
}}
|
||||||
|
placeholder="예: gwangyang"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>옵션 2 (표시명)</Label>
|
||||||
|
<Input
|
||||||
|
value={config?.dataSource?.staticOptions?.[1]?.label || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const options = config?.dataSource?.staticOptions || [];
|
||||||
|
const newOptions = [...options];
|
||||||
|
newOptions[1] = { ...newOptions[1], label: e.target.value };
|
||||||
|
handleChange("dataSource.staticOptions", newOptions);
|
||||||
|
}}
|
||||||
|
placeholder="예: 광양"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
고정된 2개 장소만 사용할 때 설정하세요. (예: 포항 ↔ 광양)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 테이블 선택 (type이 table일 때) */}
|
{/* 테이블 선택 (type이 table일 때) */}
|
||||||
{config?.dataSource?.type === "table" && (
|
{config?.dataSource?.type === "table" && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,28 @@ export class LocationSwapSelectorRenderer extends AutoRegisteringComponentRender
|
||||||
static componentDefinition = LocationSwapSelectorDefinition;
|
static componentDefinition = LocationSwapSelectorDefinition;
|
||||||
|
|
||||||
render(): React.ReactElement {
|
render(): React.ReactElement {
|
||||||
return <LocationSwapSelectorComponent {...this.props} />;
|
const { component, formData, onFormDataChange, isDesignMode, style, ...restProps } = this.props;
|
||||||
|
|
||||||
|
// component.componentConfig에서 설정 가져오기
|
||||||
|
const componentConfig = component?.componentConfig || {};
|
||||||
|
|
||||||
|
console.log("[LocationSwapSelectorRenderer] render:", {
|
||||||
|
componentConfig,
|
||||||
|
formData,
|
||||||
|
isDesignMode
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocationSwapSelectorComponent
|
||||||
|
id={component?.id}
|
||||||
|
style={style}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
formData={formData}
|
||||||
|
onFormDataChange={onFormDataChange}
|
||||||
|
componentConfig={componentConfig}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,15 @@ export const LocationSwapSelectorDefinition = createComponentDefinition({
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
// 데이터 소스 설정
|
// 데이터 소스 설정
|
||||||
dataSource: {
|
dataSource: {
|
||||||
type: "table", // "table" | "code" | "static"
|
type: "static", // "table" | "code" | "static"
|
||||||
tableName: "", // 장소 테이블명
|
tableName: "", // 장소 테이블명
|
||||||
valueField: "location_code", // 값 필드
|
valueField: "location_code", // 값 필드
|
||||||
labelField: "location_name", // 표시 필드
|
labelField: "location_name", // 표시 필드
|
||||||
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
|
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
|
||||||
staticOptions: [], // 정적 옵션 (type이 "static"일 때)
|
staticOptions: [
|
||||||
|
{ value: "pohang", label: "포항" },
|
||||||
|
{ value: "gwangyang", label: "광양" },
|
||||||
|
], // 정적 옵션 (type이 "static"일 때)
|
||||||
},
|
},
|
||||||
// 필드 매핑
|
// 필드 매핑
|
||||||
departureField: "departure", // 출발지 저장 필드
|
departureField: "departure", // 출발지 저장 필드
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ export function getApiUrl(endpoint: string): string {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const hostname = window.location.hostname;
|
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
|
// 프로덕션: v1.vexplor.com → https://api.vexplor.com
|
||||||
if (hostname === "v1.vexplor.com") {
|
if (hostname === "v1.vexplor.com") {
|
||||||
return `https://api.vexplor.com${endpoint}`;
|
return `https://api.vexplor.com${endpoint}`;
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,9 @@ export class ButtonActionExecutor {
|
||||||
case "geolocation":
|
case "geolocation":
|
||||||
return await this.handleGeolocation(config, context);
|
return await this.handleGeolocation(config, context);
|
||||||
|
|
||||||
|
case "swap_fields":
|
||||||
|
return await this.handleSwapFields(config, context);
|
||||||
|
|
||||||
case "update_field":
|
case "update_field":
|
||||||
return await this.handleUpdateField(config, context);
|
return await this.handleUpdateField(config, context);
|
||||||
|
|
||||||
|
|
@ -3412,6 +3415,59 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 값 교환 액션 처리 (예: 출발지 ↔ 도착지)
|
||||||
|
*/
|
||||||
|
private static async handleSwapFields(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log("🔄 필드 값 교환 액션 실행:", { config, context });
|
||||||
|
|
||||||
|
const { formData, onFormDataChange } = context;
|
||||||
|
|
||||||
|
// 교환할 필드 확인
|
||||||
|
const fieldA = config.swapFieldA;
|
||||||
|
const fieldB = config.swapFieldB;
|
||||||
|
|
||||||
|
if (!fieldA || !fieldB) {
|
||||||
|
toast.error("교환할 필드가 설정되지 않았습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 값 가져오기
|
||||||
|
const valueA = formData?.[fieldA];
|
||||||
|
const valueB = formData?.[fieldB];
|
||||||
|
|
||||||
|
console.log("🔄 교환 전:", { [fieldA]: valueA, [fieldB]: valueB });
|
||||||
|
|
||||||
|
// 값 교환
|
||||||
|
if (onFormDataChange) {
|
||||||
|
onFormDataChange(fieldA, valueB);
|
||||||
|
onFormDataChange(fieldB, valueA);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관련 필드도 함께 교환 (예: 위도/경도)
|
||||||
|
if (config.swapRelatedFields && config.swapRelatedFields.length > 0) {
|
||||||
|
for (const related of config.swapRelatedFields) {
|
||||||
|
const relatedValueA = formData?.[related.fieldA];
|
||||||
|
const relatedValueB = formData?.[related.fieldB];
|
||||||
|
if (onFormDataChange) {
|
||||||
|
onFormDataChange(related.fieldA, relatedValueB);
|
||||||
|
onFormDataChange(related.fieldB, relatedValueA);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔄 교환 후:", { [fieldA]: valueB, [fieldB]: valueA });
|
||||||
|
|
||||||
|
toast.success(config.successMessage || "값이 교환되었습니다.");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 필드 값 교환 오류:", error);
|
||||||
|
toast.error(config.errorMessage || "값 교환 중 오류가 발생했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 필드 값 변경 액션 처리 (예: status를 active로 변경)
|
* 필드 값 변경 액션 처리 (예: status를 active로 변경)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,17 @@ export interface LoginFormData {
|
||||||
password: string;
|
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 {
|
export interface LoginResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|
@ -18,6 +29,14 @@ export interface LoginResponse {
|
||||||
errorCode?: string;
|
errorCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SignupResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthStatus {
|
export interface AuthStatus {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue