release
This commit is contained in:
parent
0ff2f15b65
commit
b5de047716
@ -1,19 +1,18 @@
|
||||
//* public/runtime-env.js - 수정버전
|
||||
//* public/runtime-env.js - 백엔드 API 경로에 맞게 수정
|
||||
console.log('=== RUNTIME-ENV.JS 로드됨 ===');
|
||||
|
||||
window.__runtime_config__ = {
|
||||
// 기존 설정들...
|
||||
// ⚠️ 수정: 백엔드 API 구조에 맞게 URL 설정
|
||||
AUTH_URL: 'http://localhost:8081/api/auth',
|
||||
MEMBER_URL: 'http://localhost:8081/api/member',
|
||||
STORE_URL: 'http://localhost:8082/api/store',
|
||||
SALES_URL: 'http://localhost:8082/api/sales', // ← 이 줄 추가
|
||||
MENU_URL: 'http://localhost:8082/api/menu',
|
||||
SALES_URL: 'http://localhost:8082/api/sales', // store 서비스
|
||||
CONTENT_URL: 'http://localhost:8083/api/content',
|
||||
RECOMMEND_URL: 'http://localhost:8084/api/recommendation',
|
||||
RECOMMEND_URL: 'http://localhost:8084/api/recommendations', // ⚠️ 수정: 올바른 경로
|
||||
|
||||
// 프로덕션 환경 (주석 처리)
|
||||
// GATEWAY_URL: 'http://20.1.2.3',
|
||||
// STORE_URL: 'http://20.1.2.3/api/store',
|
||||
// SALES_URL: 'http://20.1.2.3/api/sales',
|
||||
// Gateway URL (운영 환경용)
|
||||
GATEWAY_URL: 'http://20.1.2.3',
|
||||
|
||||
// 기능 플래그
|
||||
FEATURES: {
|
||||
@ -21,20 +20,71 @@ window.__runtime_config__ = {
|
||||
PUSH_NOTIFICATIONS: true,
|
||||
SOCIAL_LOGIN: false,
|
||||
MULTI_LANGUAGE: false,
|
||||
API_HEALTH_CHECK: true, // ⚠️ 추가
|
||||
},
|
||||
|
||||
// 환경 설정
|
||||
ENV: 'development',
|
||||
DEBUG: true,
|
||||
|
||||
// ⚠️ 추가: API 타임아웃 설정
|
||||
API_TIMEOUT: 30000,
|
||||
|
||||
// ⚠️ 추가: 재시도 설정
|
||||
RETRY_ATTEMPTS: 3,
|
||||
RETRY_DELAY: 1000,
|
||||
|
||||
// 버전 정보
|
||||
VERSION: '1.0.0',
|
||||
BUILD_DATE: new Date().toISOString(),
|
||||
|
||||
// ⚠️ 추가: 백엔드 서비스 포트 정보 (디버깅용)
|
||||
BACKEND_PORTS: {
|
||||
AUTH: 8081,
|
||||
STORE: 8082,
|
||||
CONTENT: 8083,
|
||||
AI_RECOMMEND: 8084
|
||||
}
|
||||
};
|
||||
|
||||
console.log('=== 설정된 API URLs ===');
|
||||
console.log('AUTH_URL:', window.__runtime_config__.AUTH_URL);
|
||||
console.log('STORE_URL:', window.__runtime_config__.STORE_URL);
|
||||
console.log('SALES_URL:', window.__runtime_config__.SALES_URL);
|
||||
console.log('RECOMMEND_URL:', window.__runtime_config__.RECOMMEND_URL);
|
||||
console.log('전체 설정:', window.__runtime_config__);
|
||||
// ⚠️ 추가: 설정 검증 함수
|
||||
const validateConfig = () => {
|
||||
const config = window.__runtime_config__;
|
||||
const requiredUrls = ['AUTH_URL', 'STORE_URL', 'SALES_URL', 'RECOMMEND_URL'];
|
||||
|
||||
for (const url of requiredUrls) {
|
||||
if (!config[url]) {
|
||||
console.error(`❌ [CONFIG] 필수 URL 누락: ${url}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ [CONFIG] 설정 검증 완료');
|
||||
return true;
|
||||
};
|
||||
|
||||
// ⚠️ 추가: 개발 환경에서만 상세 로깅
|
||||
if (window.__runtime_config__.DEBUG) {
|
||||
console.log('=== 백엔드 API URLs ===');
|
||||
console.log('🔐 AUTH_URL:', window.__runtime_config__.AUTH_URL);
|
||||
console.log('🏪 STORE_URL:', window.__runtime_config__.STORE_URL);
|
||||
console.log('📊 SALES_URL:', window.__runtime_config__.SALES_URL);
|
||||
console.log('🤖 RECOMMEND_URL:', window.__runtime_config__.RECOMMEND_URL);
|
||||
console.log('📄 CONTENT_URL:', window.__runtime_config__.CONTENT_URL);
|
||||
|
||||
console.log('=== 설정 상세 정보 ===');
|
||||
console.log('전체 설정:', window.__runtime_config__);
|
||||
|
||||
// 설정 검증 실행
|
||||
validateConfig();
|
||||
}
|
||||
|
||||
// ⚠️ 추가: 전역 설정 접근 함수
|
||||
window.getApiConfig = () => window.__runtime_config__;
|
||||
window.getApiUrl = (serviceName) => {
|
||||
const config = window.__runtime_config__;
|
||||
const urlKey = `${serviceName.toUpperCase()}_URL`;
|
||||
return config[urlKey] || null;
|
||||
};
|
||||
|
||||
console.log('✅ [RUNTIME] 런타임 설정 로드 완료');
|
||||
@ -1,24 +1,20 @@
|
||||
//* src/services/api.js - 수정버전
|
||||
//* src/services/api.js - 수정된 API URL 설정
|
||||
import axios from 'axios'
|
||||
|
||||
// 런타임 환경 설정에서 API URL 가져오기
|
||||
const getApiUrls = () => {
|
||||
const config = window.__runtime_config__ || {}
|
||||
return {
|
||||
// 환경변수에서 가져오도록 수정
|
||||
GATEWAY_URL: config.GATEWAY_URL || 'http://20.1.2.3',
|
||||
AUTH_URL: config.AUTH_URL || 'http://localhost:8081/api/auth',
|
||||
MEMBER_URL: config.MEMBER_URL || 'http://localhost:8081/api/member',
|
||||
STORE_URL: config.STORE_URL || 'http://localhost:8082/api/store',
|
||||
CONTENT_URL: config.CONTENT_URL || 'http://localhost:8083/api/content',
|
||||
RECOMMEND_URL: config.RECOMMEND_URL || 'http://localhost:8084/api/recommendation',
|
||||
|
||||
// Store 서비스에 포함된 API들 - STORE_URL 기반으로 구성
|
||||
SALES_URL: (config.STORE_URL || 'http://localhost:8082') + '/api/sales',
|
||||
MENU_URL: (config.STORE_URL || 'http://localhost:8082') + '/api/menu',
|
||||
IMAGES_URL: (config.STORE_URL || 'http://localhost:8082') + '/api/images',
|
||||
|
||||
// Gateway는 필요시에만 사용
|
||||
GATEWAY_URL: config.GATEWAY_URL || 'http://20.1.2.3',
|
||||
MENU_URL: config.MENU_URL || 'http://localhost:8082/api/menu',
|
||||
// ⚠️ 수정: 매출 API는 store 서비스 (포트 8082)
|
||||
SALES_URL: config.SALES_URL || 'http://localhost:8082/api/sales',
|
||||
// ⚠️ 수정: 추천 API는 ai-recommend 서비스 (포트 8084)
|
||||
RECOMMEND_URL: config.RECOMMEND_URL || 'http://localhost:8084/api/recommendations',
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,18 +32,14 @@ const createApiInstance = (baseURL) => {
|
||||
// 요청 인터셉터 - JWT 토큰 자동 추가
|
||||
instance.interceptors.request.use(
|
||||
(config) => {
|
||||
// accessToken 또는 token 둘 다 확인
|
||||
const token = localStorage.getItem('accessToken') || localStorage.getItem('token')
|
||||
const token = localStorage.getItem('accessToken')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 디버깅용 로그 (개발 모드에서만)
|
||||
// ⚠️ 추가: 요청 로깅 (개발 환경에서만)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`API 요청: ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`)
|
||||
if (token) {
|
||||
console.log(`토큰 사용: ${token.substring(0, 20)}...`)
|
||||
}
|
||||
console.log(`🌐 [API_REQ] ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`)
|
||||
}
|
||||
|
||||
return config
|
||||
@ -60,20 +52,20 @@ const createApiInstance = (baseURL) => {
|
||||
// 응답 인터셉터 - 토큰 갱신 및 에러 처리
|
||||
instance.interceptors.response.use(
|
||||
(response) => {
|
||||
// 성공 응답 로깅 (개발 모드에서만)
|
||||
// ⚠️ 추가: 응답 로깅 (개발 환경에서만)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`API 응답: ${response.status} ${response.config.url}`)
|
||||
console.log(`✅ [API_RES] ${response.status} ${response.config?.method?.toUpperCase()} ${response.config?.url}`)
|
||||
}
|
||||
return response
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
|
||||
// 개발 모드에서 에러 로깅
|
||||
// ⚠️ 추가: 에러 로깅 (개발 환경에서만)
|
||||
if (import.meta.env.DEV) {
|
||||
console.error(`API 에러: ${error.response?.status} ${error.config?.url}`, error.response?.data)
|
||||
console.error(`❌ [API_ERR] ${error.response?.status || 'Network'} ${error.config?.method?.toUpperCase()} ${error.config?.url}`, error.response?.data)
|
||||
}
|
||||
|
||||
const originalRequest = error.config
|
||||
|
||||
// 401 에러이고 토큰 갱신을 시도하지 않은 경우
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
@ -87,7 +79,6 @@ const createApiInstance = (baseURL) => {
|
||||
|
||||
const { accessToken, refreshToken: newRefreshToken } = refreshResponse.data.data
|
||||
localStorage.setItem('accessToken', accessToken)
|
||||
localStorage.setItem('token', accessToken) // 호환성을 위해 둘 다 저장
|
||||
localStorage.setItem('refreshToken', newRefreshToken)
|
||||
|
||||
// 원래 요청에 새 토큰으로 재시도
|
||||
@ -96,8 +87,8 @@ const createApiInstance = (baseURL) => {
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// 토큰 갱신 실패 시 로그아웃 처리
|
||||
console.warn('⚠️ [TOKEN] 토큰 갱신 실패, 로그아웃 처리')
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('userInfo')
|
||||
window.location.href = '/login'
|
||||
@ -114,12 +105,9 @@ const createApiInstance = (baseURL) => {
|
||||
// API 인스턴스들 생성
|
||||
const apiUrls = getApiUrls()
|
||||
|
||||
// 디버깅용 로그 (개발 모드에서만)
|
||||
// ⚠️ 추가: API URL 확인 로깅 (개발 환경에서만)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('=== API URLs 설정 ===')
|
||||
Object.entries(apiUrls).forEach(([key, url]) => {
|
||||
console.log(`${key}: ${url}`)
|
||||
})
|
||||
console.log('🔧 [API_CONFIG] API URLs 설정:', apiUrls)
|
||||
}
|
||||
|
||||
export const memberApi = createApiInstance(apiUrls.MEMBER_URL)
|
||||
@ -129,12 +117,11 @@ export const contentApi = createApiInstance(apiUrls.CONTENT_URL)
|
||||
export const menuApi = createApiInstance(apiUrls.MENU_URL)
|
||||
export const salesApi = createApiInstance(apiUrls.SALES_URL)
|
||||
export const recommendApi = createApiInstance(apiUrls.RECOMMEND_URL)
|
||||
export const imagesApi = createApiInstance(apiUrls.IMAGES_URL)
|
||||
|
||||
// 기본 API 인스턴스 (Gateway URL 사용)
|
||||
export const api = createApiInstance(apiUrls.GATEWAY_URL)
|
||||
|
||||
// 공통 에러 핸들러 (기존과 동일)
|
||||
// 공통 에러 핸들러
|
||||
export const handleApiError = (error) => {
|
||||
const response = error.response
|
||||
|
||||
@ -197,3 +184,42 @@ export const formatSuccessResponse = (data, message = '요청이 성공적으로
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ 추가: API 상태 확인 함수
|
||||
export const checkApiHealth = async () => {
|
||||
const results = {}
|
||||
|
||||
try {
|
||||
// 각 API 서버 상태 확인
|
||||
const checks = [
|
||||
{ name: 'Auth', api: authApi, endpoint: '/health' },
|
||||
{ name: 'Store', api: storeApi, endpoint: '/health' },
|
||||
{ name: 'Sales', api: salesApi, endpoint: '/health' },
|
||||
{ name: 'Recommend', api: recommendApi, endpoint: '/health' }
|
||||
]
|
||||
|
||||
for (const check of checks) {
|
||||
try {
|
||||
await check.api.get(check.endpoint)
|
||||
results[check.name] = 'OK'
|
||||
} catch (error) {
|
||||
results[check.name] = `ERROR: ${error.response?.status || 'Network'}`
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('API 상태 확인 실패:', error)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ⚠️ 추가: 개발 환경에서 전역 노출
|
||||
if (import.meta.env.DEV) {
|
||||
window.__api_debug__ = {
|
||||
urls: apiUrls,
|
||||
instances: { memberApi, authApi, storeApi, contentApi, menuApi, salesApi, recommendApi },
|
||||
checkHealth: checkApiHealth
|
||||
}
|
||||
console.log('🔧 [DEBUG] API 인스턴스가 window.__api_debug__에 노출됨')
|
||||
}
|
||||
@ -1,70 +1,108 @@
|
||||
//* src/services/recommend.js - 수정버전
|
||||
//* src/services/recommend.js - 백엔드 API 직접 연동 버전
|
||||
import { recommendApi, handleApiError, formatSuccessResponse } from './api.js'
|
||||
|
||||
/**
|
||||
* AI 추천 관련 API 서비스
|
||||
* 유저스토리: REC-005
|
||||
* 백엔드 /api/recommendations/marketing-tips API 직접 연동
|
||||
*/
|
||||
class RecommendService {
|
||||
constructor() {
|
||||
this.lastTip = null
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 마케팅 팁 생성 (REC-005: AI 마케팅 방법 추천)
|
||||
* ⚠️ 수정: 백엔드 API 스펙에 맞게 요청 구조 변경
|
||||
* @param {Object} requestData - 마케팅 팁 요청 정보
|
||||
* AI 마케팅 팁 생성/조회 - 백엔드 API 직접 호출
|
||||
* @param {Object} requestData - 요청 데이터 (사용되지 않음)
|
||||
* @returns {Promise<Object>} 생성된 마케팅 팁
|
||||
*/
|
||||
async generateMarketingTips(requestData = {}) {
|
||||
try {
|
||||
// 백엔드 MarketingTipRequest DTO에 맞는 구조로 변경
|
||||
const requestBody = {
|
||||
storeId: requestData.storeId,
|
||||
// 필요시 추가 필드들
|
||||
additionalRequirement: requestData.additionalRequirement || '',
|
||||
console.log('🤖 [AI_TIP] 백엔드 마케팅 팁 API 직접 호출')
|
||||
|
||||
// 백엔드 API: POST /api/recommendations/marketing-tips (파라미터 없음)
|
||||
const response = await recommendApi.post('/marketing-tips')
|
||||
|
||||
console.log('📊 [AI_TIP] 응답 데이터:', response.data)
|
||||
|
||||
// 백엔드 ApiResponse 구조: { status, message, data }
|
||||
if (response.data && response.data.status === 200 && response.data.data) {
|
||||
const tipData = response.data.data
|
||||
|
||||
console.log('✅ [AI_TIP] 마케팅 팁 조회/생성 성공:', {
|
||||
tipId: tipData.tipId,
|
||||
tipSummary: tipData.tipSummary?.substring(0, 50) + '...',
|
||||
isRecentlyCreated: tipData.isRecentlyCreated,
|
||||
createdAt: tipData.createdAt
|
||||
})
|
||||
|
||||
// 캐시 저장
|
||||
this.lastTip = tipData
|
||||
|
||||
return formatSuccessResponse(tipData,
|
||||
tipData.isRecentlyCreated
|
||||
? 'AI 마케팅 팁이 새로 생성되었습니다.'
|
||||
: '최근 생성된 AI 마케팅 팁을 조회했습니다.'
|
||||
)
|
||||
} else {
|
||||
throw new Error('응답 데이터 형식 오류')
|
||||
}
|
||||
|
||||
console.log('AI 마케팅 팁 생성 요청:', requestBody)
|
||||
|
||||
const response = await recommendApi.post('/marketing-tips', requestBody)
|
||||
|
||||
return formatSuccessResponse(response.data.data, 'AI 마케팅 팁이 생성되었습니다.')
|
||||
} catch (error) {
|
||||
console.error('AI 마케팅 팁 생성 실패:', error)
|
||||
return handleApiError(error)
|
||||
console.error('❌ [AI_TIP] 마케팅 팁 API 호출 실패:', error.message)
|
||||
|
||||
// 실패시 Fallback 데이터 생성
|
||||
const fallbackTip = this.createFallbackTip()
|
||||
return formatSuccessResponse(fallbackTip,
|
||||
'AI 서비스 연결 실패로 기본 마케팅 팁을 제공합니다.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마케팅 팁 이력 조회
|
||||
* @param {number} storeId - 매장 ID
|
||||
* 마케팅 팁 이력 조회 (향후 구현)
|
||||
* @param {Object} pagination - 페이지네이션 정보
|
||||
* @returns {Promise<Object>} 마케팅 팁 이력
|
||||
*/
|
||||
async getMarketingTipHistory(storeId, pagination = {}) {
|
||||
async getMarketingTipHistory(pagination = {}) {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('storeId', storeId)
|
||||
// 현재는 캐시된 데이터가 있으면 배열로 반환
|
||||
if (this.lastTip) {
|
||||
const historyData = {
|
||||
content: [this.lastTip],
|
||||
totalElements: 1,
|
||||
totalPages: 1,
|
||||
size: 1,
|
||||
number: 0
|
||||
}
|
||||
|
||||
if (pagination.page !== undefined) params.append('page', pagination.page)
|
||||
if (pagination.size !== undefined) params.append('size', pagination.size || 10)
|
||||
if (pagination.sort) params.append('sort', pagination.sort)
|
||||
|
||||
const response = await recommendApi.get(`/marketing-tips?${params.toString()}`)
|
||||
|
||||
return formatSuccessResponse(response.data.data, '마케팅 팁 이력을 조회했습니다.')
|
||||
return formatSuccessResponse(historyData, '마케팅 팁 이력을 조회했습니다.')
|
||||
} else {
|
||||
return formatSuccessResponse({
|
||||
content: [],
|
||||
totalElements: 0,
|
||||
totalPages: 0,
|
||||
size: 0,
|
||||
number: 0
|
||||
}, '마케팅 팁 이력이 없습니다.')
|
||||
}
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마케팅 팁 상세 조회
|
||||
* 마케팅 팁 상세 조회 (향후 구현)
|
||||
* @param {number} tipId - 팁 ID
|
||||
* @returns {Promise<Object>} 마케팅 팁 상세 정보
|
||||
*/
|
||||
async getMarketingTip(tipId) {
|
||||
try {
|
||||
const response = await recommendApi.get(`/marketing-tips/${tipId}`)
|
||||
|
||||
return formatSuccessResponse(response.data.data, '마케팅 팁 상세 정보를 조회했습니다.')
|
||||
// 현재는 캐시된 데이터가 해당 ID면 반환
|
||||
if (this.lastTip && this.lastTip.tipId === tipId) {
|
||||
return formatSuccessResponse(this.lastTip, '마케팅 팁 상세 정보를 조회했습니다.')
|
||||
} else {
|
||||
throw new Error('해당 팁을 찾을 수 없습니다.')
|
||||
}
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
@ -72,20 +110,19 @@ class RecommendService {
|
||||
|
||||
/**
|
||||
* 종합 AI 추천 (대시보드용)
|
||||
* @param {number} storeId - 매장 ID
|
||||
* @returns {Promise<Object>} 통합 AI 추천 정보
|
||||
* @param {number} storeId - 매장 ID (사용되지 않음)
|
||||
*/
|
||||
async getComprehensiveRecommendation(storeId) {
|
||||
try {
|
||||
// 여러 추천 API를 병렬로 호출
|
||||
// 마케팅 팁 생성 및 이력 조회
|
||||
const [marketingTips, tipHistory] = await Promise.allSettled([
|
||||
this.generateMarketingTips({ storeId }),
|
||||
this.getMarketingTipHistory(storeId, { size: 5 })
|
||||
this.generateMarketingTips(),
|
||||
this.getMarketingTipHistory({ size: 5 })
|
||||
])
|
||||
|
||||
const result = {
|
||||
marketingTips: marketingTips.status === 'fulfilled' ? marketingTips.value : null,
|
||||
recentHistory: tipHistory.status === 'fulfilled' ? tipHistory.value : null,
|
||||
recentHistory: tipHistory.status === 'fulfilled' ? tipHistory.value : null
|
||||
}
|
||||
|
||||
return formatSuccessResponse(result, '통합 AI 추천을 조회했습니다.')
|
||||
@ -102,50 +139,37 @@ class RecommendService {
|
||||
*/
|
||||
async provideFeedback(tipId, feedback) {
|
||||
try {
|
||||
const response = await recommendApi.post(`/marketing-tips/${tipId}/feedback`, {
|
||||
rating: feedback.rating, // 1-5 점수
|
||||
useful: feedback.useful, // true/false
|
||||
comment: feedback.comment || '',
|
||||
appliedSuggestions: feedback.appliedSuggestions || [],
|
||||
})
|
||||
// 현재는 Mock 응답
|
||||
const mockResponse = {
|
||||
feedbackId: Date.now(),
|
||||
tipId: tipId,
|
||||
rating: feedback.rating,
|
||||
useful: feedback.useful,
|
||||
submittedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
return formatSuccessResponse(response.data.data, '피드백이 제공되었습니다.')
|
||||
return formatSuccessResponse(mockResponse, '피드백이 제공되었습니다.')
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 개발 모드용 Mock 추천 생성
|
||||
* @param {Object} requestData - 요청 데이터
|
||||
* @returns {Promise<Object>} Mock 추천 데이터
|
||||
* 캐시 초기화
|
||||
*/
|
||||
async generateMockRecommendation(requestData = {}) {
|
||||
// 개발 모드에서만 사용
|
||||
if (!import.meta.env.DEV) {
|
||||
return this.generateMarketingTips(requestData)
|
||||
}
|
||||
clearCache() {
|
||||
this.lastTip = null
|
||||
console.log('🧹 [AI_TIP] AI 추천 서비스 캐시 초기화')
|
||||
}
|
||||
|
||||
console.log('Mock AI 추천 생성')
|
||||
|
||||
// 2초 대기 (실제 API 호출 시뮬레이션)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
const mockData = {
|
||||
tipId: Date.now(),
|
||||
storeId: requestData.storeId,
|
||||
tipContent: `${requestData.storeId}번 매장을 위한 맞춤형 마케팅 전략을 제안드립니다.
|
||||
계절 메뉴 개발, SNS 마케팅 활용, 지역 고객 대상 이벤트 기획 등을 통해
|
||||
매출 향상과 고객 만족도를 높일 수 있습니다.`,
|
||||
storeData: {
|
||||
storeName: '테스트 매장',
|
||||
businessType: '카페',
|
||||
location: '서울시 강남구'
|
||||
},
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
return formatSuccessResponse(mockData, 'Mock AI 마케팅 팁이 생성되었습니다.')
|
||||
/**
|
||||
* 마지막 팁 반환
|
||||
*/
|
||||
getLastTip() {
|
||||
return this.lastTip
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
//* src/services/recommendationService.js - 새로 생성
|
||||
import { recommendApi, handleApiError, formatSuccessResponse } from './api.js'
|
||||
|
||||
/**
|
||||
* AI 추천 관련 API 서비스
|
||||
* API 설계서 기준
|
||||
*/
|
||||
class RecommendationService {
|
||||
/**
|
||||
* AI 마케팅 팁 생성 (REC-005: AI 마케팅 팁 생성)
|
||||
* @param {Object} requestData - 마케팅 팁 요청 정보
|
||||
* @returns {Promise<Object>} 생성된 마케팅 팁
|
||||
*/
|
||||
async generateMarketingTips(requestData) {
|
||||
try {
|
||||
const response = await recommendApi.post('/marketing-tips', {
|
||||
storeId: requestData.storeId,
|
||||
businessType: requestData.businessType,
|
||||
targetSeason: requestData.targetSeason,
|
||||
currentChallenges: requestData.currentChallenges,
|
||||
marketingGoals: requestData.marketingGoals,
|
||||
budget: requestData.budget,
|
||||
preferredChannels: requestData.preferredChannels
|
||||
})
|
||||
|
||||
return formatSuccessResponse(response.data.data, 'AI 마케팅 팁이 생성되었습니다.')
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const recommendationService = new RecommendationService()
|
||||
export default recommendationService
|
||||
@ -1,321 +1,272 @@
|
||||
//* src/services/sales.js - 스마트 데이터 탐지 버전
|
||||
//* src/services/sales.js - 백엔드 API 직접 연동 버전
|
||||
import { salesApi, handleApiError, formatSuccessResponse } from './api.js'
|
||||
|
||||
/**
|
||||
* 매출 관련 API 서비스 - 스마트 데이터 탐지 버전
|
||||
* 매출 관련 API 서비스 - 백엔드 직접 연동
|
||||
*/
|
||||
class SalesService {
|
||||
/**
|
||||
* 현재 사용자의 매출 정보 조회 (JWT 기반)
|
||||
*/
|
||||
async getMySales() {
|
||||
try {
|
||||
const response = await salesApi.get('/my')
|
||||
return formatSuccessResponse(response.data.data, '내 매출 정보를 조회했습니다.')
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
constructor() {
|
||||
this.fallbackData = this.createFallbackData()
|
||||
this.cachedStoreId = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장 매출 정보 조회
|
||||
* 매장 매출 정보 조회 - 백엔드 /api/sales/{storeId} 직접 호출
|
||||
* @param {number} storeId - 매장 ID
|
||||
* @returns {Promise<Object>} 매출 정보
|
||||
*/
|
||||
async getSales(storeId) {
|
||||
try {
|
||||
console.log(`🔗 [SALES_API] 백엔드 매출 API 직접 호출: /api/sales/${storeId}`)
|
||||
|
||||
const response = await salesApi.get(`/${storeId}`)
|
||||
return formatSuccessResponse(response.data.data, '매출 정보를 조회했습니다.')
|
||||
|
||||
console.log('📊 [SALES_API] 응답 데이터:', response.data)
|
||||
|
||||
// 백엔드 ApiResponse 구조: { status, message, data }
|
||||
if (response.data && response.data.status === 200 && response.data.data) {
|
||||
const salesData = response.data.data
|
||||
|
||||
// BigDecimal을 숫자로 변환
|
||||
const processedData = {
|
||||
todaySales: Number(salesData.todaySales) || 0,
|
||||
monthSales: Number(salesData.monthSales) || 0,
|
||||
previousDayComparison: Number(salesData.previousDayComparison) || 0,
|
||||
previousDayChangeRate: Number(salesData.previousDayChangeRate) || 0,
|
||||
goalAchievementRate: Number(salesData.goalAchievementRate) || 0,
|
||||
yearSales: salesData.yearSales || []
|
||||
}
|
||||
|
||||
console.log('✅ [SALES_API] 매출 데이터 변환 완료:', {
|
||||
todaySales: processedData.todaySales,
|
||||
monthSales: processedData.monthSales,
|
||||
yearSalesCount: processedData.yearSales.length
|
||||
})
|
||||
|
||||
return formatSuccessResponse(processedData, '매출 정보를 조회했습니다.')
|
||||
} else {
|
||||
throw new Error('응답 데이터 형식 오류')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [SALES_API] 매장 ${storeId} 매출 조회 실패:`, error.message)
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 데이터가 있는 Store 자동 탐지 🔍
|
||||
* 스마트 매출 조회 - 여러 매장 ID 시도 후 성공하는 것 사용
|
||||
* @param {boolean} useCache - 캐시 사용 여부
|
||||
* @returns {Promise<Object>} 매출 정보
|
||||
*/
|
||||
async findStoreWithData(maxStoreId = 50) {
|
||||
console.log(`🔍 [DETECTOR] 실제 데이터 탐지 시작 (1~${maxStoreId}번까지)`)
|
||||
async getSalesWithSmartDetection(useCache = true) {
|
||||
console.log('🎯 [SMART_SALES] 스마트 매출 조회 시작')
|
||||
|
||||
const foundStores = []
|
||||
const errors = []
|
||||
|
||||
// 1~maxStoreId까지 모든 Store ID 시도
|
||||
for (let storeId = 1; storeId <= maxStoreId; storeId++) {
|
||||
// 1. 캐시된 매장 ID 시도
|
||||
if (useCache && this.cachedStoreId) {
|
||||
try {
|
||||
console.log(`📡 [SCAN] Store ${storeId} 스캔 중... (${storeId}/${maxStoreId})`)
|
||||
|
||||
const result = await this.getSales(storeId)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 데이터 품질 검사
|
||||
const dataQuality = this.checkDataQuality(result.data)
|
||||
|
||||
if (dataQuality.hasRealData) {
|
||||
console.log(`✅ [FOUND] Store ${storeId}에서 실제 데이터 발견!`, {
|
||||
todaySales: result.data.todaySales,
|
||||
monthSales: result.data.monthSales,
|
||||
yearSalesCount: result.data.yearSales?.length || 0,
|
||||
quality: dataQuality
|
||||
})
|
||||
|
||||
foundStores.push({
|
||||
storeId,
|
||||
data: result.data,
|
||||
quality: dataQuality,
|
||||
foundAt: new Date().toLocaleTimeString()
|
||||
})
|
||||
|
||||
// 첫 번째 실제 데이터를 찾으면 즉시 반환 (옵션)
|
||||
// return foundStores[0]
|
||||
} else {
|
||||
console.log(`⚠️ [EMPTY] Store ${storeId}에 빈 데이터:`, dataQuality)
|
||||
console.log(`📡 [CACHE] 캐시된 매장 ${this.cachedStoreId} 시도`)
|
||||
const result = await this.getSales(this.cachedStoreId)
|
||||
if (result.success) {
|
||||
console.log('✅ [CACHE] 캐시된 매장 성공')
|
||||
return {
|
||||
...result,
|
||||
method: 'CACHE',
|
||||
foundStoreId: this.cachedStoreId
|
||||
}
|
||||
} else {
|
||||
console.debug(`❌ [FAIL] Store ${storeId}: ${result.message}`)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// 개별 Store 에러는 기록만 하고 계속 진행
|
||||
const errorInfo = {
|
||||
storeId,
|
||||
error: error.message,
|
||||
status: error.response?.status,
|
||||
type: this.classifyError(error)
|
||||
}
|
||||
|
||||
errors.push(errorInfo)
|
||||
|
||||
// 404는 정상(데이터 없음), 다른 에러는 로깅
|
||||
if (error.response?.status !== 404) {
|
||||
console.debug(`⚠️ [ERROR] Store ${storeId}: ${errorInfo.type} - ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 서버 부하 방지를 위한 짧은 대기 (개발 중에는 제거 가능)
|
||||
if (storeId % 10 === 0) {
|
||||
console.log(`⏸️ [PAUSE] ${storeId}번까지 스캔 완료, 잠시 대기...`)
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
console.warn('⚠️ [CACHE] 캐시된 매장 실패:', error.message)
|
||||
this.cachedStoreId = null
|
||||
}
|
||||
}
|
||||
|
||||
// 탐지 결과 요약
|
||||
console.log('📊 [SUMMARY] 데이터 탐지 완료:', {
|
||||
totalScanned: maxStoreId,
|
||||
foundStores: foundStores.length,
|
||||
errors: errors.length,
|
||||
errorTypes: this.summarizeErrors(errors)
|
||||
})
|
||||
// 2. 1~10번 매장 순차 시도 (빠른 탐지)
|
||||
console.log('🔍 [AUTO] 자동 매장 탐지 시작 (1~10번)')
|
||||
for (let storeId = 1; storeId <= 10; storeId++) {
|
||||
try {
|
||||
console.log(`📡 [AUTO] 매장 ${storeId} 시도`)
|
||||
const result = await this.getSales(storeId)
|
||||
|
||||
if (foundStores.length > 0) {
|
||||
// 품질 점수가 높은 순으로 정렬
|
||||
foundStores.sort((a, b) => b.quality.score - a.quality.score)
|
||||
if (result.success && result.data) {
|
||||
// 실제 데이터가 있는지 확인
|
||||
const hasRealData = this.checkDataQuality(result.data).hasRealData
|
||||
|
||||
console.log('🏆 [BEST] 최고 품질 데이터:', {
|
||||
storeId: foundStores[0].storeId,
|
||||
score: foundStores[0].quality.score,
|
||||
reasons: foundStores[0].quality.reasons
|
||||
})
|
||||
if (hasRealData) {
|
||||
console.log(`🎉 [AUTO] 매장 ${storeId}에서 실제 데이터 발견!`)
|
||||
this.cachedStoreId = storeId
|
||||
|
||||
return {
|
||||
success: true,
|
||||
bestStore: foundStores[0],
|
||||
allStores: foundStores,
|
||||
totalFound: foundStores.length
|
||||
}
|
||||
} else {
|
||||
console.warn('❌ [NOT_FOUND] 실제 데이터를 가진 Store를 찾지 못했습니다')
|
||||
return {
|
||||
success: false,
|
||||
bestStore: null,
|
||||
allStores: [],
|
||||
totalFound: 0,
|
||||
errors: errors
|
||||
return {
|
||||
...result,
|
||||
method: 'AUTO_DETECTION',
|
||||
foundStoreId: storeId,
|
||||
message: `매장 ${storeId}의 실제 매출 데이터`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 매장 간 짧은 대기
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
} catch (error) {
|
||||
console.log(`❌ [AUTO] 매장 ${storeId} 실패: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 실제 데이터를 찾지 못하면 폴백 데이터 사용
|
||||
console.log('🔄 [FALLBACK] 실제 데이터 미발견, 폴백 데이터 사용')
|
||||
return {
|
||||
success: true,
|
||||
data: this.fallbackData,
|
||||
method: 'FALLBACK',
|
||||
message: '데모 데이터를 사용합니다'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 품질 검사 📋
|
||||
* 데이터 품질 검사
|
||||
* @param {Object} data - 매출 데이터
|
||||
* @returns {Object} 품질 정보
|
||||
*/
|
||||
checkDataQuality(data) {
|
||||
const quality = {
|
||||
hasRealData: false,
|
||||
score: 0,
|
||||
reasons: [],
|
||||
issues: []
|
||||
reasons: []
|
||||
}
|
||||
|
||||
// 1. 기본 데이터 존재 여부
|
||||
// 1. 오늘 매출 체크
|
||||
if (data.todaySales && Number(data.todaySales) > 0) {
|
||||
quality.score += 30
|
||||
quality.reasons.push('오늘 매출 데이터 존재')
|
||||
} else {
|
||||
quality.issues.push('오늘 매출 없음')
|
||||
quality.reasons.push(`오늘 매출: ${Number(data.todaySales).toLocaleString()}원`)
|
||||
}
|
||||
|
||||
// 2. 월간 매출 체크
|
||||
if (data.monthSales && Number(data.monthSales) > 0) {
|
||||
quality.score += 30
|
||||
quality.reasons.push('월간 매출 데이터 존재')
|
||||
} else {
|
||||
quality.issues.push('월간 매출 없음')
|
||||
quality.score += 25
|
||||
quality.reasons.push(`월간 매출: ${Number(data.monthSales).toLocaleString()}원`)
|
||||
}
|
||||
|
||||
// 2. 연간 데이터 품질
|
||||
// 3. 연간 데이터 체크
|
||||
if (data.yearSales && Array.isArray(data.yearSales) && data.yearSales.length > 0) {
|
||||
quality.score += 25
|
||||
quality.reasons.push(`연간 매출 ${data.yearSales.length}건`)
|
||||
quality.score += 20
|
||||
quality.reasons.push(`연간 데이터 ${data.yearSales.length}건`)
|
||||
|
||||
// 실제 금액이 있는 데이터 개수 확인
|
||||
// 실제 금액이 있는 데이터 확인
|
||||
const validSales = data.yearSales.filter(sale =>
|
||||
sale.salesAmount && Number(sale.salesAmount) > 0
|
||||
)
|
||||
|
||||
if (validSales.length > 0) {
|
||||
quality.score += 15
|
||||
quality.reasons.push(`유효한 매출 ${validSales.length}건`)
|
||||
if (validSales.length > 5) {
|
||||
quality.score += 25
|
||||
quality.reasons.push(`유효 매출 ${validSales.length}건`)
|
||||
}
|
||||
} else {
|
||||
quality.issues.push('연간 매출 데이터 없음')
|
||||
}
|
||||
|
||||
// 3. 전일 대비 데이터
|
||||
if (data.previousDayComparison !== undefined) {
|
||||
quality.score += 10
|
||||
quality.reasons.push('전일 대비 데이터 존재')
|
||||
}
|
||||
|
||||
// 4. 품질 점수가 50점 이상이면 실제 데이터로 판정
|
||||
quality.hasRealData = quality.score >= 50
|
||||
|
||||
// 5. 품질 등급 매기기
|
||||
if (quality.score >= 90) quality.grade = 'A'
|
||||
else if (quality.score >= 70) quality.grade = 'B'
|
||||
else if (quality.score >= 50) quality.grade = 'C'
|
||||
else quality.grade = 'D'
|
||||
quality.hasRealData = quality.score >= 60
|
||||
|
||||
return quality
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 분류 🏷️
|
||||
* 폴백 데이터 생성
|
||||
*/
|
||||
classifyError(error) {
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 404: return 'NOT_FOUND'
|
||||
case 401: return 'UNAUTHORIZED'
|
||||
case 403: return 'FORBIDDEN'
|
||||
case 500: return 'SERVER_ERROR'
|
||||
default: return `HTTP_${error.response.status}`
|
||||
}
|
||||
} else if (error.code === 'NETWORK_ERROR') {
|
||||
return 'NETWORK_ERROR'
|
||||
} else {
|
||||
return 'UNKNOWN_ERROR'
|
||||
createFallbackData() {
|
||||
const today = new Date()
|
||||
const yearSales = []
|
||||
|
||||
// 최근 30일 데이터 생성
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const date = new Date(today)
|
||||
date.setDate(date.getDate() - i)
|
||||
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
||||
const baseAmount = isWeekend ? 450000 : 280000
|
||||
const randomFactor = 0.7 + Math.random() * 0.6
|
||||
const salesAmount = Math.floor(baseAmount * randomFactor)
|
||||
|
||||
yearSales.push({
|
||||
salesDate: date.toISOString().split('T')[0],
|
||||
salesAmount: salesAmount
|
||||
})
|
||||
}
|
||||
|
||||
const todaySales = yearSales[yearSales.length - 1].salesAmount
|
||||
const yesterdaySales = yearSales[yearSales.length - 2].salesAmount
|
||||
const monthSales = yearSales.slice(-30).reduce((sum, sale) => sum + sale.salesAmount, 0)
|
||||
const previousDayComparison = todaySales - yesterdaySales
|
||||
|
||||
return {
|
||||
todaySales,
|
||||
monthSales,
|
||||
yearSales,
|
||||
previousDayComparison,
|
||||
goalAchievementRate: 85.2,
|
||||
isDemo: true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 요약 📊
|
||||
* 빠른 매출 조회 (타임아웃 포함)
|
||||
*/
|
||||
summarizeErrors(errors) {
|
||||
const summary = {}
|
||||
errors.forEach(error => {
|
||||
summary[error.type] = (summary[error.type] || 0) + 1
|
||||
})
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* 스마트 매출 조회 - 데이터 탐지 기반 🎯
|
||||
*/
|
||||
async getSalesWithSmartDetection(storeId = null) {
|
||||
console.log('🎯 [SMART] 스마트 매출 조회 시작')
|
||||
|
||||
// 1. 먼저 JWT 기반 조회 시도
|
||||
async getQuickSales() {
|
||||
try {
|
||||
console.log('📡 [JWT] JWT 기반 매출 조회 시도')
|
||||
const result = await this.getMySales()
|
||||
if (result.success && result.data) {
|
||||
const quality = this.checkDataQuality(result.data)
|
||||
if (quality.hasRealData) {
|
||||
console.log('✅ [JWT] JWT 기반 매출 조회 성공 (실제 데이터)')
|
||||
return {
|
||||
...result,
|
||||
method: 'JWT',
|
||||
quality
|
||||
}
|
||||
}
|
||||
}
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('타임아웃')), 5000)
|
||||
)
|
||||
|
||||
const dataPromise = this.getSalesWithSmartDetection(true)
|
||||
|
||||
const result = await Promise.race([dataPromise, timeoutPromise])
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('⚠️ [JWT] JWT 기반 매출 조회 실패:', error.message)
|
||||
}
|
||||
|
||||
// 2. 지정된 storeId가 있으면 먼저 시도
|
||||
if (storeId) {
|
||||
try {
|
||||
console.log(`📡 [SPECIFIED] Store ${storeId} 우선 시도`)
|
||||
const result = await this.getSales(storeId)
|
||||
if (result.success && result.data) {
|
||||
const quality = this.checkDataQuality(result.data)
|
||||
if (quality.hasRealData) {
|
||||
console.log(`✅ [SPECIFIED] Store ${storeId} 성공 (실제 데이터)`)
|
||||
return {
|
||||
...result,
|
||||
method: 'SPECIFIED',
|
||||
foundStoreId: storeId,
|
||||
quality
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ [SPECIFIED] Store ${storeId} 실패:`, error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 자동 탐지로 실제 데이터가 있는 Store 찾기
|
||||
console.log('🔍 [AUTO] 자동 데이터 탐지 시작')
|
||||
const detectionResult = await this.findStoreWithData(30) // 30개까지 스캔
|
||||
|
||||
if (detectionResult.success && detectionResult.bestStore) {
|
||||
console.log('🎉 [AUTO] 자동 탐지 성공!')
|
||||
console.warn('⚠️ [QUICK] 빠른 조회 실패, 폴백 데이터 사용:', error.message)
|
||||
return {
|
||||
success: true,
|
||||
data: detectionResult.bestStore.data,
|
||||
method: 'AUTO_DETECTION',
|
||||
foundStoreId: detectionResult.bestStore.storeId,
|
||||
quality: detectionResult.bestStore.quality,
|
||||
totalFound: detectionResult.totalFound,
|
||||
message: `Store ${detectionResult.bestStore.storeId}에서 실제 데이터 발견`
|
||||
data: this.fallbackData,
|
||||
method: 'QUICK_FALLBACK',
|
||||
message: '네트워크 지연으로 데모 데이터를 사용합니다'
|
||||
}
|
||||
} else {
|
||||
console.error('❌ [AUTO] 자동 탐지 실패 - 실제 데이터를 찾지 못했습니다')
|
||||
throw new Error('실제 매출 데이터를 찾을 수 없습니다')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 Store ID 테스트
|
||||
* 캐시 초기화
|
||||
*/
|
||||
clearCache() {
|
||||
this.cachedStoreId = null
|
||||
console.log('🧹 [CACHE] 매출 서비스 캐시 초기화')
|
||||
}
|
||||
|
||||
/**
|
||||
* 최적 매장 ID 반환
|
||||
*/
|
||||
getBestStoreId() {
|
||||
return this.cachedStoreId || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 매장 테스트 (디버깅용)
|
||||
*/
|
||||
async testSpecificStore(storeId) {
|
||||
try {
|
||||
console.log(`🧪 [TEST] Store ${storeId} 테스트`)
|
||||
console.log(`🧪 [TEST] 매장 ${storeId} 테스트`)
|
||||
const result = await this.getSales(storeId)
|
||||
|
||||
if (result.success && result.data) {
|
||||
const quality = this.checkDataQuality(result.data)
|
||||
console.log(`📊 [TEST] Store ${storeId} 결과:`, {
|
||||
console.log(`📊 [TEST] 매장 ${storeId} 결과:`, {
|
||||
hasData: quality.hasRealData,
|
||||
grade: quality.grade,
|
||||
score: quality.score,
|
||||
reasons: quality.reasons
|
||||
})
|
||||
return { ...result, quality }
|
||||
} else {
|
||||
console.warn(`⚠️ [TEST] Store ${storeId} 실패:`, result.message)
|
||||
console.warn(`⚠️ [TEST] 매장 ${storeId} 실패:`, result.message)
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [TEST] Store ${storeId} 에러:`, error)
|
||||
console.error(`❌ [TEST] 매장 ${storeId} 에러:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,170 +1,277 @@
|
||||
//* src/services/store.js - 수정버전
|
||||
import { storeApi, handleApiError, formatSuccessResponse } from './api.js'
|
||||
//* src/services/store.js
|
||||
import { api } from './api'
|
||||
import { formatSuccessResponse, formatErrorResponse, handleApiError } from '@/utils/api-helpers'
|
||||
|
||||
/**
|
||||
* 매장 관련 API 서비스
|
||||
* 유저스토리: STR-005, STR-010, STR-015, STR-020, STR-025, STR-030, STR-035, STR-040
|
||||
* 매장 정보 조회, 수정, 매출 정보 등을 처리합니다.
|
||||
*/
|
||||
class StoreService {
|
||||
/**
|
||||
* 매장 등록 (STR-015: 매장 등록)
|
||||
* @param {Object} storeData - 매장 정보
|
||||
* @returns {Promise<Object>} 매장 등록 결과
|
||||
*/
|
||||
async registerStore(storeData) {
|
||||
try {
|
||||
const response = await storeApi.post('/register', {
|
||||
storeName: storeData.storeName,
|
||||
storeImage: storeData.storeImage,
|
||||
businessType: storeData.businessType,
|
||||
address: storeData.address,
|
||||
phoneNumber: storeData.phoneNumber,
|
||||
businessNumber: storeData.businessNumber,
|
||||
instaAccount: storeData.instaAccount,
|
||||
naverBlogAccount: storeData.naverBlogAccount,
|
||||
operatingHours: storeData.operatingHours,
|
||||
closedDays: storeData.closedDays,
|
||||
seatCount: storeData.seatCount,
|
||||
})
|
||||
|
||||
return formatSuccessResponse(response.data.data, '매장이 등록되었습니다.')
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
constructor() {
|
||||
this.baseURL = '/api/store'
|
||||
this.salesURL = '/api/sales'
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장 정보 조회 (현재 로그인 사용자) - STR-005: 매장 정보 관리
|
||||
* 매장 정보 조회 (STR-001: 매장 정보 조회)
|
||||
* @returns {Promise<Object>} 매장 정보
|
||||
*/
|
||||
async getStore() {
|
||||
try {
|
||||
const response = await storeApi.get('/')
|
||||
return formatSuccessResponse(response.data.data, '매장 정보를 조회했습니다.')
|
||||
const response = await api.get(`${this.baseURL}/info`)
|
||||
|
||||
if (response.data.success) {
|
||||
return {
|
||||
success: true,
|
||||
message: '매장 정보를 조회했습니다.',
|
||||
data: response.data.data
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data.message || '매장 정보 조회에 실패했습니다.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('매장 정보 조회 실패:', error)
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장 정보 수정 (STR-010: 매장 수정)
|
||||
* @param {Object} storeData - 수정할 매장 정보 (storeId 불필요 - JWT에서 추출)
|
||||
* @returns {Promise<Object>} 매장 수정 결과
|
||||
* 매장 정보 수정 (STR-002: 매장 정보 수정)
|
||||
* @param {Object} storeData - 수정할 매장 정보
|
||||
* @returns {Promise<Object>} 수정된 매장 정보
|
||||
*/
|
||||
async updateStore(storeData) {
|
||||
try {
|
||||
const response = await storeApi.put('/', storeData)
|
||||
return formatSuccessResponse(response.data.data, '매장 정보가 수정되었습니다.')
|
||||
const response = await api.put(`${this.baseURL}/info`, storeData)
|
||||
|
||||
if (response.data.success) {
|
||||
return {
|
||||
success: true,
|
||||
message: '매장 정보를 수정했습니다.',
|
||||
data: response.data.data
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data.message || '매장 정보 수정에 실패했습니다.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('매장 정보 수정 실패:', error)
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 등록 (STR-030: 메뉴 등록)
|
||||
* @param {Object} menuData - 메뉴 정보
|
||||
* @returns {Promise<Object>} 메뉴 등록 결과
|
||||
* 매출 정보 조회 (STR-020: 대시보드)
|
||||
* @param {number} storeId - 매장 ID (기본값: 1)
|
||||
* @returns {Promise<Object>} 매출 정보
|
||||
*/
|
||||
async registerMenu(menuData) {
|
||||
async getSales(storeId = 1) {
|
||||
try {
|
||||
const response = await storeApi.post('/menu/register', {
|
||||
menuName: menuData.menuName,
|
||||
menuCategory: menuData.menuCategory,
|
||||
menuImage: menuData.menuImage,
|
||||
price: menuData.price,
|
||||
description: menuData.description,
|
||||
isPopular: menuData.isPopular || false,
|
||||
isRecommended: menuData.isRecommended || false,
|
||||
})
|
||||
console.log('매출 조회 API 호출:', `${this.salesURL}/${storeId}`)
|
||||
|
||||
return formatSuccessResponse(response.data.data, '메뉴가 등록되었습니다.')
|
||||
const response = await api.get(`${this.salesURL}/${storeId}`)
|
||||
|
||||
if (response.data.success) {
|
||||
const salesData = response.data.data
|
||||
|
||||
// API 응답 데이터 로그
|
||||
console.log('매출 API 응답:', salesData)
|
||||
|
||||
// yearSales 데이터가 있는지 확인하고 변곡점 계산
|
||||
let processedData = {
|
||||
todaySales: salesData.todaySales || 0,
|
||||
monthSales: salesData.monthSales || 0,
|
||||
previousDayComparison: salesData.previousDayComparison || 0,
|
||||
yearSales: salesData.yearSales || []
|
||||
}
|
||||
|
||||
// 변곡점 분석 추가
|
||||
if (salesData.yearSales && salesData.yearSales.length > 0) {
|
||||
processedData.trendAnalysis = this.analyzeSalesTrend(salesData.yearSales)
|
||||
processedData.chartData = this.prepareChartData(salesData.yearSales)
|
||||
}
|
||||
|
||||
return formatSuccessResponse(processedData, '매출 정보를 조회했습니다.')
|
||||
} else {
|
||||
throw new Error(response.data.message || '매출 정보 조회에 실패했습니다.')
|
||||
}
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
console.error('매출 정보 조회 실패:', error)
|
||||
|
||||
// API 오류 시 기본 데이터 반환 (개발 단계)
|
||||
const fallbackData = {
|
||||
todaySales: 170000,
|
||||
monthSales: 4500000,
|
||||
previousDayComparison: 15000,
|
||||
yearSales: [],
|
||||
trendAnalysis: {
|
||||
inflectionPoints: [],
|
||||
overallTrend: 'stable',
|
||||
growthRate: 0
|
||||
},
|
||||
chartData: {
|
||||
labels: [],
|
||||
salesData: [],
|
||||
targetData: []
|
||||
}
|
||||
}
|
||||
|
||||
return formatSuccessResponse(fallbackData, '임시 매출 데이터를 표시합니다.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 목록 조회 (STR-025: 메뉴 조회)
|
||||
* @param {Object} filters - 필터링 옵션
|
||||
* 매출 트렌드 분석 및 변곡점 계산
|
||||
* @param {Array} yearSales - 연간 매출 데이터 (Sales 엔티티 배열)
|
||||
* @returns {Object} 트렌드 분석 결과
|
||||
*/
|
||||
analyzeSalesTrend(yearSales) {
|
||||
if (!yearSales || yearSales.length < 7) {
|
||||
return {
|
||||
inflectionPoints: [],
|
||||
overallTrend: 'insufficient_data',
|
||||
growthRate: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜순 정렬 (salesDate 기준)
|
||||
const sortedData = [...yearSales].sort((a, b) =>
|
||||
new Date(a.salesDate) - new Date(b.salesDate)
|
||||
)
|
||||
|
||||
const inflectionPoints = []
|
||||
const dailyData = sortedData.map(item => ({
|
||||
date: item.salesDate,
|
||||
amount: parseFloat(item.salesAmount) || 0
|
||||
}))
|
||||
|
||||
// 7일 이동평균으로 변곡점 탐지
|
||||
for (let i = 7; i < dailyData.length - 7; i++) {
|
||||
const prevWeekAvg = this.calculateMovingAverage(dailyData, i - 7, 7)
|
||||
const currentWeekAvg = this.calculateMovingAverage(dailyData, i, 7)
|
||||
const nextWeekAvg = this.calculateMovingAverage(dailyData, i + 7, 7)
|
||||
|
||||
// 변곡점 조건: 이전 주 → 현재 주 → 다음 주의 트렌드 변화
|
||||
const trend1 = currentWeekAvg - prevWeekAvg
|
||||
const trend2 = nextWeekAvg - currentWeekAvg
|
||||
|
||||
// 트렌드 방향이 바뀌고 변화량이 일정 이상인 경우
|
||||
if (Math.sign(trend1) !== Math.sign(trend2) &&
|
||||
Math.abs(trend1) > 10000 && Math.abs(trend2) > 10000) {
|
||||
|
||||
inflectionPoints.push({
|
||||
date: dailyData[i].date,
|
||||
amount: dailyData[i].amount,
|
||||
type: trend1 > 0 ? 'peak' : 'valley',
|
||||
significance: Math.abs(trend1) + Math.abs(trend2)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 전체적인 트렌드 계산
|
||||
const firstWeekAvg = this.calculateMovingAverage(dailyData, 0, 7)
|
||||
const lastWeekAvg = this.calculateMovingAverage(dailyData, dailyData.length - 7, 7)
|
||||
const growthRate = ((lastWeekAvg - firstWeekAvg) / firstWeekAvg) * 100
|
||||
|
||||
let overallTrend = 'stable'
|
||||
if (Math.abs(growthRate) > 5) {
|
||||
overallTrend = growthRate > 0 ? 'increasing' : 'decreasing'
|
||||
}
|
||||
|
||||
console.log('변곡점 분석 결과:', { inflectionPoints, overallTrend, growthRate })
|
||||
|
||||
return {
|
||||
inflectionPoints: inflectionPoints.slice(0, 5), // 상위 5개만
|
||||
overallTrend,
|
||||
growthRate: Math.round(growthRate * 10) / 10
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이동평균 계산
|
||||
* @param {Array} data - 데이터 배열
|
||||
* @param {number} startIndex - 시작 인덱스
|
||||
* @param {number} period - 기간
|
||||
* @returns {number} 이동평균값
|
||||
*/
|
||||
calculateMovingAverage(data, startIndex, period) {
|
||||
const slice = data.slice(startIndex, startIndex + period)
|
||||
const sum = slice.reduce((acc, item) => acc + item.amount, 0)
|
||||
return sum / slice.length
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트용 데이터 준비
|
||||
* @param {Array} yearSales - 연간 매출 데이터
|
||||
* @returns {Object} 차트 데이터
|
||||
*/
|
||||
prepareChartData(yearSales) {
|
||||
if (!yearSales || yearSales.length === 0) {
|
||||
return { labels: [], salesData: [], targetData: [] }
|
||||
}
|
||||
|
||||
// 최근 90일 데이터만 사용 (차트 표시용)
|
||||
const sortedData = [...yearSales]
|
||||
.sort((a, b) => new Date(a.salesDate) - new Date(b.salesDate))
|
||||
.slice(-90)
|
||||
|
||||
const labels = sortedData.map(item => {
|
||||
const date = new Date(item.salesDate)
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
})
|
||||
|
||||
const salesData = sortedData.map(item => parseFloat(item.salesAmount) || 0)
|
||||
|
||||
// 목표 매출 라인 (평균의 110%)
|
||||
const averageSales = salesData.reduce((a, b) => a + b, 0) / salesData.length
|
||||
const targetData = salesData.map(() => averageSales * 1.1)
|
||||
|
||||
return {
|
||||
labels,
|
||||
salesData,
|
||||
targetData
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 목록 조회 (개발 예정)
|
||||
* @returns {Promise<Object>} 메뉴 목록
|
||||
*/
|
||||
async getMenus(filters = {}) {
|
||||
async getMenus() {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
// 현재는 목업 데이터 반환 (추후 실제 API 연동 시 수정)
|
||||
const mockMenus = [
|
||||
{
|
||||
id: 1,
|
||||
name: '아메리카노',
|
||||
price: 4000,
|
||||
category: '커피',
|
||||
description: '진한 풍미의 아메리카노',
|
||||
imageUrl: '/images/americano.jpg',
|
||||
isAvailable: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '카페라떼',
|
||||
price: 4500,
|
||||
category: '커피',
|
||||
description: '부드러운 우유가 들어간 라떼',
|
||||
imageUrl: '/images/latte.jpg',
|
||||
isAvailable: true
|
||||
}
|
||||
]
|
||||
|
||||
if (filters.category) queryParams.append('category', filters.category)
|
||||
if (filters.sortBy) queryParams.append('sortBy', filters.sortBy)
|
||||
if (filters.isPopular !== undefined) queryParams.append('isPopular', filters.isPopular)
|
||||
|
||||
const response = await storeApi.get(`/menu?${queryParams.toString()}`)
|
||||
|
||||
return formatSuccessResponse(response.data.data, '메뉴 목록을 조회했습니다.')
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 수정 (STR-035: 메뉴 수정)
|
||||
* @param {number} menuId - 메뉴 ID
|
||||
* @param {Object} menuData - 수정할 메뉴 정보
|
||||
* @returns {Promise<Object>} 메뉴 수정 결과
|
||||
*/
|
||||
async updateMenu(menuId, menuData) {
|
||||
try {
|
||||
const response = await storeApi.put(`/menu/${menuId}`, menuData)
|
||||
|
||||
return formatSuccessResponse(response.data.data, '메뉴가 수정되었습니다.')
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 삭제 (STR-040: 메뉴 삭제)
|
||||
* @param {number} menuId - 메뉴 ID
|
||||
* @returns {Promise<Object>} 메뉴 삭제 결과
|
||||
*/
|
||||
async deleteMenu(menuId) {
|
||||
try {
|
||||
await storeApi.delete(`/menu/${menuId}`)
|
||||
|
||||
return formatSuccessResponse(null, '메뉴가 삭제되었습니다.')
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다중 메뉴 삭제
|
||||
* @param {number[]} menuIds - 삭제할 메뉴 ID 배열
|
||||
* @returns {Promise<Object>} 삭제 결과
|
||||
*/
|
||||
async deleteMenus(menuIds) {
|
||||
try {
|
||||
const deletePromises = menuIds.map((menuId) => this.deleteMenu(menuId))
|
||||
await Promise.all(deletePromises)
|
||||
|
||||
return formatSuccessResponse(null, `${menuIds.length}개의 메뉴가 삭제되었습니다.`)
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장 통계 정보 조회
|
||||
* @returns {Promise<Object>} 매장 통계
|
||||
*/
|
||||
async getStoreStatistics() {
|
||||
try {
|
||||
const response = await storeApi.get('/statistics')
|
||||
|
||||
return formatSuccessResponse(response.data.data, '매장 통계를 조회했습니다.')
|
||||
return formatSuccessResponse(mockMenus, '메뉴 목록을 조회했습니다.')
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성 및 export
|
||||
export const storeService = new StoreService()
|
||||
export default storeService
|
||||
|
||||
// 디버깅을 위한 전역 노출 (개발 환경에서만)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
window.storeService = storeService
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
//* src/views/DashboardView.vue - 완전 수정버전
|
||||
//* src/views/DashboardView.vue - 차트 연동 수정버전
|
||||
<template>
|
||||
<div>
|
||||
<!-- 메인 컨텐츠 -->
|
||||
@ -415,7 +415,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed, nextTick } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useAppStore } from '@/store/app'
|
||||
@ -450,6 +450,13 @@ const aiError = ref('')
|
||||
const storeInfo = ref(null)
|
||||
const currentStoreId = ref(null)
|
||||
|
||||
// ⚠️ 실제 원본 차트 데이터 저장 (원화 단위)
|
||||
const originalChartData = ref({
|
||||
'7d': [],
|
||||
'30d': [],
|
||||
'90d': []
|
||||
})
|
||||
|
||||
// 툴팁 관련
|
||||
const tooltip = ref({
|
||||
show: false,
|
||||
@ -517,11 +524,46 @@ const chartData = ref({
|
||||
],
|
||||
})
|
||||
|
||||
const yAxisLabels = ref(['0', '25', '50', '75', '100'])
|
||||
// Y축 라벨 동적 계산
|
||||
const yAxisLabels = computed(() => {
|
||||
const data = currentChartData.value
|
||||
if (!data || data.length === 0) return ['0', '25', '50', '75', '100']
|
||||
|
||||
const maxValue = Math.max(...data.map(d => Math.max(d.sales, d.target)))
|
||||
const step = Math.ceil(maxValue / 5)
|
||||
|
||||
return Array.from({ length: 6 }, (_, i) => (i * step).toString())
|
||||
})
|
||||
|
||||
// AI 추천 데이터 (초기값 - API에서 업데이트됨)
|
||||
const aiRecommendation = ref(null)
|
||||
|
||||
|
||||
|
||||
// 성공 리포트 생성 함수 추가
|
||||
const generateSuccessReport = (detectionResults) => {
|
||||
if (!detectionResults) return
|
||||
|
||||
console.log('📋 [SUCCESS_REPORT] 데이터 로드 성공 리포트:')
|
||||
console.log(' - 매출 탐지 방법:', detectionResults.method)
|
||||
console.log(' - 발견된 Store ID:', detectionResults.foundStoreId)
|
||||
console.log(' - 데이터 품질 점수:', detectionResults.quality?.score)
|
||||
console.log(' - 총 발견 건수:', detectionResults.totalFound)
|
||||
}
|
||||
|
||||
// 추정 매장 정보 생성 함수 추가
|
||||
const createEstimatedStoreInfo = (detectionResults) => {
|
||||
storeInfo.value = {
|
||||
storeId: currentStoreId.value,
|
||||
storeName: `Store ${currentStoreId.value}`,
|
||||
storeType: 'UNKNOWN',
|
||||
location: '위치 정보 없음',
|
||||
createdDate: new Date().toISOString(),
|
||||
isEstimated: true
|
||||
}
|
||||
console.log('🔄 [ESTIMATED] 추정 매장 정보 생성:', storeInfo.value)
|
||||
}
|
||||
|
||||
// ⚠️ API 연동 함수들 수정
|
||||
|
||||
/**
|
||||
@ -612,9 +654,7 @@ const loadStoreAndSalesData = async () => {
|
||||
appStore.showSnackbar('매출 데이터 로드에 실패해 테스트 데이터를 표시합니다', 'warning')
|
||||
}
|
||||
|
||||
// Mock 데이터 사용
|
||||
useMockSalesData()
|
||||
salesDataLoaded = false
|
||||
|
||||
}
|
||||
|
||||
// 🏪 2. 매장 정보 로드 (매출 성공 여부와 무관하게 시도)
|
||||
@ -648,9 +688,6 @@ const loadStoreAndSalesData = async () => {
|
||||
createEstimatedStoreInfo(detectionResults)
|
||||
storeDataLoaded = true
|
||||
console.log('🔄 [STORE] 매출 기반 추정 매장 정보 생성 완료')
|
||||
} else {
|
||||
useMockStoreData()
|
||||
storeDataLoaded = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -691,10 +728,7 @@ const loadStoreAndSalesData = async () => {
|
||||
} catch (unexpectedError) {
|
||||
console.error('🚨 [UNEXPECTED] 예상치 못한 에러:', unexpectedError)
|
||||
|
||||
// 최후의 수단
|
||||
useMockStoreData()
|
||||
useMockSalesData()
|
||||
appStore.showSnackbar('시스템 오류로 인해 테스트 데이터를 표시합니다.', 'error')
|
||||
|
||||
|
||||
} finally {
|
||||
loading.value = false
|
||||
@ -702,20 +736,6 @@ const loadStoreAndSalesData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 매장 데이터 사용 (개발/테스트용)
|
||||
*/
|
||||
const useMockStoreData = () => {
|
||||
console.log('Mock 매장 데이터 사용')
|
||||
storeInfo.value = {
|
||||
storeId: 1,
|
||||
storeName: '테스트 카페',
|
||||
businessType: '카페',
|
||||
address: '서울시 강남구',
|
||||
phoneNumber: '02-1234-5678'
|
||||
}
|
||||
currentStoreId.value = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 지표 업데이트 (수정)
|
||||
@ -772,16 +792,16 @@ const updateDashboardMetrics = (salesData) => {
|
||||
startMetricsAnimation()
|
||||
} catch (error) {
|
||||
console.error('대시보드 지표 업데이트 실패:', error)
|
||||
// 에러 발생 시 기본값 사용
|
||||
useMockSalesData()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 데이터 업데이트 (개선)
|
||||
* 차트 데이터 업데이트 (수정 - 핵심 차트 연동 로직)
|
||||
*/
|
||||
const updateChartData = (salesData) => {
|
||||
try {
|
||||
console.log('📊 [CHART] 차트 데이터 업데이트 시작:', salesData)
|
||||
|
||||
// yearSales 데이터가 있으면 차트 데이터 업데이트
|
||||
if (salesData.yearSales && salesData.yearSales.length > 0) {
|
||||
// Sales 엔티티 배열을 차트 형식으로 변환
|
||||
@ -789,22 +809,104 @@ const updateChartData = (salesData) => {
|
||||
const date = new Date(sale.salesDate)
|
||||
const label = `${date.getMonth() + 1}/${date.getDate()}`
|
||||
const amount = Number(sale.salesAmount) / 10000 // 만원 단위로 변환
|
||||
const originalAmount = Number(sale.salesAmount) // 원화 단위 원본 저장
|
||||
|
||||
return {
|
||||
label: index === salesData.yearSales.length - 1 ? '오늘' : label,
|
||||
sales: Math.round(amount),
|
||||
target: Math.round(amount * 1.1), // 목표는 실제 매출의 110%로 설정
|
||||
date: sale.salesDate
|
||||
date: sale.salesDate,
|
||||
originalSales: originalAmount, // ⚠️ 원화 단위 원본 추가
|
||||
originalTarget: Math.round(originalAmount * 1.1) // ⚠️ 원화 단위 목표 추가
|
||||
}
|
||||
})
|
||||
|
||||
console.log('📊 [CHART] 변환된 7일 데이터:', salesDataPoints)
|
||||
|
||||
// 7일 차트 데이터 업데이트
|
||||
chartData.value['7d'] = salesDataPoints
|
||||
originalChartData.value['7d'] = salesDataPoints // ⚠️ 원본 데이터 저장
|
||||
|
||||
console.log('차트 데이터 업데이트 완료:', salesDataPoints)
|
||||
// 30일/90일 데이터 생성 (실제 데이터 기반)
|
||||
if (salesData.yearSales.length >= 30) {
|
||||
// 30일 데이터를 주간으로 그룹화
|
||||
const weeklyData = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const weekStart = Math.max(0, salesData.yearSales.length - 35 + (i * 7))
|
||||
const weekEnd = Math.min(salesData.yearSales.length, weekStart + 7)
|
||||
const weekSales = salesData.yearSales.slice(weekStart, weekEnd)
|
||||
|
||||
if (weekSales.length > 0) {
|
||||
const totalAmount = weekSales.reduce((sum, sale) => sum + Number(sale.salesAmount), 0)
|
||||
const avgAmount = totalAmount / weekSales.length / 10000 // 만원 단위
|
||||
const originalAvgAmount = totalAmount / weekSales.length // 원화 단위
|
||||
|
||||
weeklyData.push({
|
||||
label: i === 4 ? '이번주' : `${i + 1}주차`,
|
||||
sales: Math.round(avgAmount),
|
||||
target: Math.round(avgAmount * 1.1),
|
||||
date: `Week ${i + 1}`,
|
||||
originalSales: Math.round(originalAvgAmount), // ⚠️ 원화 단위 원본
|
||||
originalTarget: Math.round(originalAvgAmount * 1.1) // ⚠️ 원화 단위 목표
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (weeklyData.length > 0) {
|
||||
chartData.value['30d'] = weeklyData
|
||||
originalChartData.value['30d'] = weeklyData // ⚠️ 원본 데이터 저장
|
||||
console.log('📊 [CHART] 30일(주간) 데이터 생성:', weeklyData)
|
||||
}
|
||||
}
|
||||
|
||||
if (salesData.yearSales.length >= 90) {
|
||||
// 90일 데이터를 월간으로 그룹화
|
||||
const monthlyData = []
|
||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']
|
||||
|
||||
// 최근 4개월 데이터 생성
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const monthStart = Math.max(0, salesData.yearSales.length - 120 + (i * 30))
|
||||
const monthEnd = Math.min(salesData.yearSales.length, monthStart + 30)
|
||||
const monthSales = salesData.yearSales.slice(monthStart, monthEnd)
|
||||
|
||||
if (monthSales.length > 0) {
|
||||
const totalAmount = monthSales.reduce((sum, sale) => sum + Number(sale.salesAmount), 0)
|
||||
const avgAmount = totalAmount / monthSales.length / 10000 // 만원 단위
|
||||
const originalAvgAmount = totalAmount / monthSales.length // 원화 단위
|
||||
|
||||
const currentMonth = new Date().getMonth()
|
||||
const monthIndex = (currentMonth - 3 + i + 12) % 12
|
||||
|
||||
monthlyData.push({
|
||||
label: i === 3 ? '이번달' : monthNames[monthIndex],
|
||||
sales: Math.round(avgAmount * 10), // 월간은 10배 스케일
|
||||
target: Math.round(avgAmount * 11),
|
||||
date: `Month ${i + 1}`,
|
||||
originalSales: Math.round(originalAvgAmount * 10), // ⚠️ 원화 단위 원본
|
||||
originalTarget: Math.round(originalAvgAmount * 11) // ⚠️ 원화 단위 목표
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (monthlyData.length > 0) {
|
||||
chartData.value['90d'] = monthlyData
|
||||
originalChartData.value['90d'] = monthlyData // ⚠️ 원본 데이터 저장
|
||||
console.log('📊 [CHART] 90일(월간) 데이터 생성:', monthlyData)
|
||||
}
|
||||
}
|
||||
|
||||
// 차트 다시 그리기
|
||||
nextTick(() => {
|
||||
drawChart()
|
||||
})
|
||||
|
||||
console.log('📊 [CHART] 차트 데이터 업데이트 완료')
|
||||
} else {
|
||||
console.warn('⚠️ [CHART] yearSales 데이터가 없음, 기본 차트 유지')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('차트 데이터 업데이트 실패:', error)
|
||||
console.error('❌ [CHART] 차트 데이터 업데이트 실패:', error)
|
||||
// 실패 시 기본 차트 데이터 유지
|
||||
}
|
||||
}
|
||||
@ -873,13 +975,8 @@ const updateAiRecommendation = (aiData) => {
|
||||
title: aiData.tipContent ? aiData.tipContent.substring(0, 50) + '...' : 'AI 마케팅 추천',
|
||||
sections: {
|
||||
ideas: {
|
||||
title: '1. 추천 아이디어',
|
||||
title: '추천 아이디어',
|
||||
items: [aiData.tipContent || '맞춤형 마케팅 전략을 제안드립니다.']
|
||||
},
|
||||
costs: {
|
||||
title: '2. 예상 효과',
|
||||
items: ['고객 관심 유도 및 매출 상승', 'SNS를 통한 브랜드 인지도 상승'],
|
||||
effects: ['재방문율 및 공유 유도', '지역 내 인지도 향상']
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -890,37 +987,14 @@ const updateAiRecommendation = (aiData) => {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fallback AI 추천 사용
|
||||
*/
|
||||
const useFallbackAiRecommendation = () => {
|
||||
console.log('Fallback AI 추천 사용')
|
||||
aiRecommendation.value = {
|
||||
emoji: '☀️',
|
||||
title: '여름 시즌 마케팅 전략',
|
||||
sections: {
|
||||
ideas: {
|
||||
title: '1. 기본 추천사항',
|
||||
items: [
|
||||
'계절 메뉴 개발 및 프로모션',
|
||||
'SNS 마케팅 활용',
|
||||
'지역 고객 대상 이벤트 기획'
|
||||
]
|
||||
},
|
||||
costs: {
|
||||
title: '2. 기대 효과',
|
||||
items: ['매출 향상', '고객 만족도 증가'],
|
||||
effects: ['브랜드 인지도 상승', '재방문 고객 증가']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 계산된 속성들 (기존과 동일)
|
||||
const currentChartData = computed(() => chartData.value[chartPeriod.value])
|
||||
|
||||
const chartDataPoints = computed(() => {
|
||||
const data = currentChartData.value
|
||||
if (!data || data.length === 0) return []
|
||||
|
||||
const maxSales = Math.max(...data.map(d => Math.max(d.sales, d.target)))
|
||||
|
||||
return data.map((item, index) => {
|
||||
@ -941,6 +1015,8 @@ const chartDataPoints = computed(() => {
|
||||
|
||||
const avgSales = computed(() => {
|
||||
const data = currentChartData.value
|
||||
if (!data || data.length === 0) return '₩0'
|
||||
|
||||
const avg = data.reduce((sum, item) => sum + item.sales, 0) / data.length
|
||||
const unit = chartPeriod.value === '90d' ? 100 : chartPeriod.value === '30d' ? 10 : 1
|
||||
return formatCurrency(avg * unit * 10000)
|
||||
@ -948,6 +1024,8 @@ const avgSales = computed(() => {
|
||||
|
||||
const maxSales = computed(() => {
|
||||
const data = currentChartData.value
|
||||
if (!data || data.length === 0) return '₩0'
|
||||
|
||||
const max = Math.max(...data.map(d => d.sales))
|
||||
const unit = chartPeriod.value === '90d' ? 100 : chartPeriod.value === '30d' ? 10 : 1
|
||||
return formatCurrency(max * unit * 10000)
|
||||
@ -955,6 +1033,8 @@ const maxSales = computed(() => {
|
||||
|
||||
const achievementRate = computed(() => {
|
||||
const data = currentChartData.value
|
||||
if (!data || data.length === 0) return 0
|
||||
|
||||
const totalSales = data.reduce((sum, item) => sum + item.sales, 0)
|
||||
const totalTarget = data.reduce((sum, item) => sum + item.target, 0)
|
||||
return Math.round((totalSales / totalTarget) * 100)
|
||||
@ -1005,15 +1085,33 @@ const startMetricsAnimation = () => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 그리기 (수정 - 안전성 강화)
|
||||
*/
|
||||
const drawChart = async () => {
|
||||
await nextTick()
|
||||
|
||||
if (!chartCanvas.value) return
|
||||
if (!chartCanvas.value) {
|
||||
console.warn('⚠️ [CHART] Canvas 요소를 찾을 수 없음')
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = chartCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
const data = currentChartData.value
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
console.warn('⚠️ [CHART] 차트 데이터가 없음')
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📊 [DRAW] 차트 그리기 시작:', {
|
||||
period: chartPeriod.value,
|
||||
dataLength: data.length,
|
||||
canvasSize: `${canvas.width}x${canvas.height}`
|
||||
})
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const padding = 60
|
||||
@ -1022,6 +1120,11 @@ const drawChart = async () => {
|
||||
|
||||
const maxValue = Math.max(...data.map(d => Math.max(d.sales, d.target)))
|
||||
|
||||
if (maxValue === 0) {
|
||||
console.warn('⚠️ [CHART] 최대값이 0이므로 차트를 그릴 수 없음')
|
||||
return
|
||||
}
|
||||
|
||||
// 매출 라인 그리기
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = '#1976D2'
|
||||
@ -1061,26 +1164,65 @@ const drawChart = async () => {
|
||||
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
|
||||
console.log('✅ [DRAW] 차트 그리기 완료')
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 업데이트 (수정 - 차트 재그리기 강화)
|
||||
*/
|
||||
const updateChart = async (period) => {
|
||||
console.log('차트 기간 변경:', period)
|
||||
console.log('📊 [UPDATE] 차트 기간 변경:', period)
|
||||
chartPeriod.value = period
|
||||
|
||||
// nextTick을 사용하여 DOM 업데이트 후 차트 그리기
|
||||
await nextTick()
|
||||
drawChart()
|
||||
|
||||
// 약간의 지연 후 차트 다시 그리기 (UI 업데이트 완료 대기)
|
||||
setTimeout(() => {
|
||||
drawChart()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 툴팁 표시 - 실제 API 데이터 반영 수정
|
||||
*/
|
||||
const showDataTooltip = (index, event) => {
|
||||
const data = currentChartData.value[index]
|
||||
const unit = chartPeriod.value === '90d' ? 100 : chartPeriod.value === '30d' ? 10 : 1
|
||||
const originalData = originalChartData.value[chartPeriod.value]?.[index]
|
||||
|
||||
if (!data) return
|
||||
|
||||
// 차트 영역의 위치 계산
|
||||
const chartArea = event.target.closest('.chart-area')
|
||||
const rect = chartArea.getBoundingClientRect()
|
||||
|
||||
// ⚠️ 원본 데이터가 있으면 사용, 없으면 기본 변환 로직 사용
|
||||
let actualSales, actualTarget
|
||||
|
||||
if (originalData && originalData.originalSales && originalData.originalTarget) {
|
||||
// 실제 API 데이터의 원화 단위 사용
|
||||
actualSales = originalData.originalSales
|
||||
actualTarget = originalData.originalTarget
|
||||
console.log('🔍 [TOOLTIP] 원본 API 데이터 사용:', { actualSales, actualTarget })
|
||||
} else {
|
||||
// Fallback: 기존 변환 로직 사용
|
||||
const unit = chartPeriod.value === '90d' ? 100 : chartPeriod.value === '30d' ? 10 : 1
|
||||
actualSales = data.sales * unit * 10000
|
||||
actualTarget = data.target * unit * 10000
|
||||
console.log('🔍 [TOOLTIP] 변환된 데이터 사용:', { actualSales, actualTarget, unit })
|
||||
}
|
||||
|
||||
tooltip.value = {
|
||||
show: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY - 80,
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top - 80,
|
||||
title: data.label,
|
||||
sales: data.sales * unit * 10000,
|
||||
target: data.target * unit * 10000
|
||||
sales: actualSales,
|
||||
target: actualTarget
|
||||
}
|
||||
|
||||
console.log('📊 [TOOLTIP] 툴팁 표시:', tooltip.value)
|
||||
}
|
||||
|
||||
const hideDataTooltip = () => {
|
||||
@ -1151,6 +1293,24 @@ const confirmLogout = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ 차트 기간 변경 감지 (새로 추가)
|
||||
watch(chartPeriod, (newPeriod) => {
|
||||
console.log('📊 [WATCH] 차트 기간 변경 감지:', newPeriod)
|
||||
nextTick(() => {
|
||||
drawChart()
|
||||
})
|
||||
})
|
||||
|
||||
// ⚠️ 차트 데이터 변경 감지 (새로 추가)
|
||||
watch(currentChartData, (newData) => {
|
||||
console.log('📊 [WATCH] 차트 데이터 변경 감지:', newData?.length, '개 항목')
|
||||
if (newData && newData.length > 0) {
|
||||
nextTick(() => {
|
||||
drawChart()
|
||||
})
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// ⚠️ onMounted 수정 - 함수명 변경
|
||||
onMounted(async () => {
|
||||
console.log('DashboardView 마운트됨')
|
||||
@ -1165,14 +1325,14 @@ onMounted(async () => {
|
||||
// 매장 정보 및 매출 데이터 로드
|
||||
await loadStoreAndSalesData() // ← 함수명 변경
|
||||
|
||||
// 차트 그리기
|
||||
// 차트 그리기 (데이터 로드 후)
|
||||
await nextTick()
|
||||
drawChart()
|
||||
|
||||
// AI 추천 초기 로드
|
||||
refreshAiRecommendation()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
animatedValues.value = {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -1294,6 +1454,7 @@ onBeforeUnmount(() => {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 40px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.y-label {
|
||||
@ -1309,6 +1470,7 @@ onBeforeUnmount(() => {
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.grid-line {
|
||||
@ -1325,6 +1487,7 @@ onBeforeUnmount(() => {
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.data-points {
|
||||
@ -1333,6 +1496,7 @@ onBeforeUnmount(() => {
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.data-point {
|
||||
@ -1383,8 +1547,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.chart-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
position: absolute;
|
||||
z-index: 99999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@ -1402,10 +1566,6 @@ onBeforeUnmount(() => {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tooltip-sales,
|
||||
.tooltip-target {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
/* AI 추천 카드 새로운 스타일 */
|
||||
.ai-recommend-card {
|
||||
|
||||
@ -69,16 +69,78 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 차트 영역 (Chart.js 없이 표시) -->
|
||||
<!-- 차트 영역 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="8">
|
||||
<v-card elevation="2">
|
||||
<v-card-title>매출 추이</v-card-title>
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>📈 매출 추이 분석</span>
|
||||
<div v-if="salesData.trendAnalysis">
|
||||
<v-chip
|
||||
:color="getTrendColor(salesData.trendAnalysis.overallTrend)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ getTrendLabel(salesData.trendAnalysis.overallTrend) }}
|
||||
</v-chip>
|
||||
<v-chip color="info" size="small" variant="tonal">
|
||||
{{ salesData.trendAnalysis.growthRate }}% 변화
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="chart-placeholder">
|
||||
<v-icon size="64" color="grey-lighten-2">mdi-chart-line</v-icon>
|
||||
<p class="text-grey mt-2">차트 라이브러리 로딩 중...</p>
|
||||
<p class="text-caption text-grey">Chart.js를 설치하면 실제 차트가 표시됩니다</p>
|
||||
<!-- 변곡점 정보 -->
|
||||
<div v-if="salesData.trendAnalysis && salesData.trendAnalysis.inflectionPoints.length > 0" class="mb-4">
|
||||
<h4 class="text-subtitle-1 font-weight-bold mb-2">🔍 주요 변곡점</h4>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-chip
|
||||
v-for="(point, index) in salesData.trendAnalysis.inflectionPoints.slice(0, 3)"
|
||||
:key="index"
|
||||
:color="point.type === 'peak' ? 'success' : 'warning'"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
>
|
||||
<v-icon size="14" class="mr-1">
|
||||
{{ point.type === 'peak' ? 'mdi-arrow-up-bold' : 'mdi-arrow-down-bold' }}
|
||||
</v-icon>
|
||||
{{ formatDate(point.date) }} - {{ formatCurrency(point.amount) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 간단한 차트 표현 (Chart.js 없이) -->
|
||||
<div class="chart-container">
|
||||
<div v-if="salesData.chartData && salesData.chartData.salesData.length > 0" class="simple-chart">
|
||||
<h4 class="text-subtitle-2 mb-3">최근 90일 매출 추이</h4>
|
||||
<div class="chart-bars">
|
||||
<div
|
||||
v-for="(amount, index) in getChartDisplayData()"
|
||||
:key="index"
|
||||
class="chart-bar"
|
||||
:style="{
|
||||
height: `${getBarHeight(amount)}px`,
|
||||
backgroundColor: getBarColor(amount, index)
|
||||
}"
|
||||
:title="`${salesData.chartData.labels[index]}: ${formatCurrency(amount)}`"
|
||||
></div>
|
||||
</div>
|
||||
<div class="chart-labels">
|
||||
<span
|
||||
v-for="(label, index) in getChartLabels()"
|
||||
:key="index"
|
||||
class="chart-label"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="chart-placeholder">
|
||||
<v-icon size="64" color="grey-lighten-2">mdi-chart-line</v-icon>
|
||||
<p class="text-grey mt-2">매출 데이터를 불러오는 중...</p>
|
||||
<p class="text-caption text-grey">잠시 후 다시 시도해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@ -87,62 +149,83 @@
|
||||
<!-- 매출 순위 -->
|
||||
<v-col cols="12" md="4">
|
||||
<v-card elevation="2">
|
||||
<v-card-title>인기 메뉴 순위</v-card-title>
|
||||
<v-card-title>📊 매출 요약</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-if="topMenus.length > 0">
|
||||
<div
|
||||
v-for="(menu, index) in topMenus"
|
||||
:key="menu.id"
|
||||
class="d-flex align-center mb-3"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-space-between align-center mb-2">
|
||||
<span class="text-body-2">오늘 매출</span>
|
||||
<span class="text-h6 font-weight-bold text-primary">
|
||||
{{ formatCurrency(salesData.todaySales) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between align-center mb-2">
|
||||
<span class="text-body-2">이번 달 매출</span>
|
||||
<span class="text-h6 font-weight-bold text-success">
|
||||
{{ formatCurrency(salesData.monthSales) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<span class="text-body-2">전일 대비</span>
|
||||
<v-chip
|
||||
:color="index === 0 ? 'warning' : index === 1 ? 'grey' : 'orange'"
|
||||
:color="salesData.previousDayComparison >= 0 ? 'success' : 'error'"
|
||||
size="small"
|
||||
class="mr-3"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
<v-icon size="14" class="mr-1">
|
||||
{{ salesData.previousDayComparison >= 0 ? 'mdi-trending-up' : 'mdi-trending-down' }}
|
||||
</v-icon>
|
||||
{{ formatCurrency(Math.abs(salesData.previousDayComparison)) }}
|
||||
</v-chip>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-body-2 font-weight-medium">
|
||||
{{ menu.name }}
|
||||
</div>
|
||||
<div class="text-caption text-grey">
|
||||
{{ formatCurrency(menu.sales) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-body-2 font-weight-bold">{{ menu.quantity }}개</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center pa-4">
|
||||
<v-icon size="48" color="grey-lighten-2">mdi-food-off</v-icon>
|
||||
<p class="text-grey mt-2">메뉴 데이터가 없습니다</p>
|
||||
|
||||
<v-divider class="mb-3"></v-divider>
|
||||
|
||||
<!-- 인기 메뉴 (기존 목업 데이터 유지) -->
|
||||
<h4 class="text-subtitle-1 font-weight-bold mb-2">인기 메뉴 순위</h4>
|
||||
<div v-if="topMenus.length > 0">
|
||||
<div
|
||||
v-for="(menu, index) in topMenus.slice(0, 3)"
|
||||
:key="menu.id"
|
||||
class="d-flex align-center justify-space-between mb-2"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<v-chip
|
||||
:color="index === 0 ? 'warning' : index === 1 ? 'info' : 'success'"
|
||||
size="x-small"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</v-chip>
|
||||
<span class="text-body-2">{{ menu.name }}</span>
|
||||
</div>
|
||||
<span class="text-caption text-grey">{{ formatCurrency(menu.sales) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 상세 분석 테이블 -->
|
||||
<v-row>
|
||||
<!-- 상세 데이터 테이블 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-card elevation="2">
|
||||
<v-card-title>상세 매출 분석</v-card-title>
|
||||
<v-card-title>📋 일별 매출 상세</v-card-title>
|
||||
<v-card-text>
|
||||
<v-data-table
|
||||
:headers="tableHeaders"
|
||||
:items="salesData"
|
||||
:loading="loading"
|
||||
no-data-text="데이터가 없습니다"
|
||||
loading-text="데이터를 불러오는 중..."
|
||||
:items="tableData"
|
||||
:items-per-page="10"
|
||||
class="elevation-0"
|
||||
>
|
||||
<template v-slot:item.sales="{ item }">
|
||||
<span class="font-weight-bold">
|
||||
{{ formatCurrency(item.sales) }}
|
||||
</span>
|
||||
<template #item.sales="{ item }">
|
||||
{{ formatCurrency(item.sales) }}
|
||||
</template>
|
||||
<template v-slot:item.change="{ item }">
|
||||
<template #item.average="{ item }">
|
||||
{{ formatCurrency(item.average) }}
|
||||
</template>
|
||||
<template #item.change="{ item }">
|
||||
<v-chip
|
||||
:color="item.change > 0 ? 'success' : item.change < 0 ? 'error' : 'warning'"
|
||||
size="small"
|
||||
@ -165,18 +248,26 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { formatCurrency } from '@/utils/formatters'
|
||||
// import Chart from 'chart.js/auto' // Chart.js 설치 후 주석 해제
|
||||
import { storeService } from '@/services/store'
|
||||
|
||||
/**
|
||||
* 매출 분석 페이지
|
||||
* 매출 분석 페이지 - 실제 API 데이터 연동
|
||||
*/
|
||||
|
||||
// 상태 관리
|
||||
const loading = ref(false)
|
||||
const selectedPeriod = ref('week')
|
||||
const selectedMetric = ref('sales')
|
||||
const salesData = ref({
|
||||
todaySales: 0,
|
||||
monthSales: 0,
|
||||
previousDayComparison: 0,
|
||||
yearSales: [],
|
||||
trendAnalysis: null,
|
||||
chartData: null
|
||||
})
|
||||
|
||||
// 옵션 데이터
|
||||
const periodOptions = ref([
|
||||
@ -193,43 +284,50 @@ const metricOptions = ref([
|
||||
{ title: '평균 주문금액', value: 'average' },
|
||||
])
|
||||
|
||||
// 주요 지표
|
||||
const mainMetrics = ref([
|
||||
{
|
||||
title: '총 매출',
|
||||
value: '₩8,750,000',
|
||||
change: '+15.2%',
|
||||
trend: 'up',
|
||||
icon: 'mdi-cash',
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
title: '주문 수',
|
||||
value: '1,245',
|
||||
change: '+8.7%',
|
||||
trend: 'up',
|
||||
icon: 'mdi-cart',
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
title: '평균 주문액',
|
||||
value: '₩7,025',
|
||||
change: '+3.1%',
|
||||
trend: 'up',
|
||||
icon: 'mdi-calculator',
|
||||
color: 'info',
|
||||
},
|
||||
{
|
||||
title: '고객 수',
|
||||
value: '892',
|
||||
change: '+12.5%',
|
||||
trend: 'up',
|
||||
icon: 'mdi-account-group',
|
||||
color: 'warning',
|
||||
},
|
||||
])
|
||||
// 계산된 주요 지표
|
||||
const mainMetrics = computed(() => {
|
||||
const dayChange = salesData.value.previousDayComparison || 0
|
||||
const dayChangePercent = salesData.value.todaySales > 0
|
||||
? ((dayChange / salesData.value.todaySales) * 100).toFixed(1)
|
||||
: '0.0'
|
||||
|
||||
// 인기 메뉴
|
||||
return [
|
||||
{
|
||||
title: '오늘 매출',
|
||||
value: formatCurrency(salesData.value.todaySales),
|
||||
change: `${dayChange >= 0 ? '+' : ''}${dayChangePercent}%`,
|
||||
trend: dayChange > 0 ? 'up' : dayChange < 0 ? 'down' : 'neutral',
|
||||
icon: 'mdi-cash',
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
title: '이달 매출',
|
||||
value: formatCurrency(salesData.value.monthSales),
|
||||
change: '+12.5%', // TODO: 월간 변화율 API 추가 필요
|
||||
trend: 'up',
|
||||
icon: 'mdi-chart-line',
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
title: '전일 대비',
|
||||
value: formatCurrency(Math.abs(dayChange)),
|
||||
change: dayChange >= 0 ? '증가' : '감소',
|
||||
trend: dayChange >= 0 ? 'up' : 'down',
|
||||
icon: 'mdi-trending-up',
|
||||
color: dayChange >= 0 ? 'success' : 'error',
|
||||
},
|
||||
{
|
||||
title: '성장률',
|
||||
value: salesData.value.trendAnalysis ? `${salesData.value.trendAnalysis.growthRate}%` : '0%',
|
||||
change: '연간 기준',
|
||||
trend: (salesData.value.trendAnalysis?.growthRate || 0) >= 0 ? 'up' : 'down',
|
||||
icon: 'mdi-calculator',
|
||||
color: 'info',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
// 인기 메뉴 (기존 목업 데이터 유지)
|
||||
const topMenus = ref([
|
||||
{ id: 1, name: '떡볶이', sales: 1250000, quantity: 450 },
|
||||
{ id: 2, name: '순대', sales: 980000, quantity: 320 },
|
||||
@ -247,54 +345,47 @@ const tableHeaders = ref([
|
||||
{ title: '전일 대비', key: 'change', sortable: true },
|
||||
])
|
||||
|
||||
// 테이블 데이터
|
||||
const salesData = ref([
|
||||
{
|
||||
date: '2024-01-15',
|
||||
sales: 450000,
|
||||
orders: 65,
|
||||
average: 6923,
|
||||
change: 12.5,
|
||||
},
|
||||
{
|
||||
date: '2024-01-14',
|
||||
sales: 380000,
|
||||
orders: 58,
|
||||
average: 6552,
|
||||
change: -3.2,
|
||||
},
|
||||
{
|
||||
date: '2024-01-13',
|
||||
sales: 520000,
|
||||
orders: 72,
|
||||
average: 7222,
|
||||
change: 18.7,
|
||||
},
|
||||
{
|
||||
date: '2024-01-12',
|
||||
sales: 425000,
|
||||
orders: 61,
|
||||
average: 6967,
|
||||
change: 8.1,
|
||||
},
|
||||
{
|
||||
date: '2024-01-11',
|
||||
sales: 390000,
|
||||
orders: 55,
|
||||
average: 7091,
|
||||
change: 5.4,
|
||||
},
|
||||
])
|
||||
// 테이블 데이터 (yearSales 기반으로 생성)
|
||||
const tableData = computed(() => {
|
||||
if (!salesData.value.yearSales || salesData.value.yearSales.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 최근 10일 데이터만 표시
|
||||
const recent = salesData.value.yearSales
|
||||
.sort((a, b) => new Date(b.salesDate) - new Date(a.salesDate))
|
||||
.slice(0, 10)
|
||||
|
||||
return recent.map((item, index) => {
|
||||
const sales = parseFloat(item.salesAmount) || 0
|
||||
const prevItem = recent[index + 1]
|
||||
const prevSales = prevItem ? parseFloat(prevItem.salesAmount) || 0 : sales
|
||||
const change = prevSales > 0 ? ((sales - prevSales) / prevSales * 100).toFixed(1) : 0
|
||||
|
||||
return {
|
||||
date: formatDate(item.salesDate),
|
||||
sales: sales,
|
||||
orders: Math.floor(sales / 8000), // 추정 주문수
|
||||
average: sales > 0 ? Math.floor(sales / Math.max(1, Math.floor(sales / 8000))) : 0,
|
||||
change: parseFloat(change)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 메서드
|
||||
const refreshData = async () => {
|
||||
const refreshData = async (storeId = 1) => {
|
||||
try {
|
||||
loading.value = true
|
||||
console.log('매출 데이터 새로고침 시작')
|
||||
|
||||
// API 호출 시뮬레이션
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
const response = await storeService.getSales(storeId)
|
||||
|
||||
console.log('매출 데이터 새로고침 완료')
|
||||
if (response.success) {
|
||||
salesData.value = response.data
|
||||
console.log('매출 데이터 로드 완료:', response.data)
|
||||
} else {
|
||||
console.error('매출 데이터 로드 실패:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('데이터 새로고침 실패:', error)
|
||||
} finally {
|
||||
@ -312,63 +403,75 @@ const updateMetric = (metric) => {
|
||||
refreshData()
|
||||
}
|
||||
|
||||
// Chart.js 초기화 (설치 후 사용)
|
||||
/*
|
||||
const initChart = () => {
|
||||
const ctx = document.getElementById('salesChart')
|
||||
if (ctx) {
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['1/11', '1/12', '1/13', '1/14', '1/15'],
|
||||
datasets: [{
|
||||
label: '매출액',
|
||||
data: [390000, 425000, 520000, 380000, 450000],
|
||||
borderColor: '#1976D2',
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.1)',
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return '₩' + value.toLocaleString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// 트렌드 관련 헬퍼 함수
|
||||
const getTrendColor = (trend) => {
|
||||
switch (trend) {
|
||||
case 'increasing': return 'success'
|
||||
case 'decreasing': return 'error'
|
||||
case 'stable': return 'info'
|
||||
default: return 'grey'
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
const getTrendLabel = (trend) => {
|
||||
switch (trend) {
|
||||
case 'increasing': return '상승 추세'
|
||||
case 'decreasing': return '하락 추세'
|
||||
case 'stable': return '안정적'
|
||||
default: return '데이터 부족'
|
||||
}
|
||||
}
|
||||
|
||||
// 차트 관련 함수
|
||||
const getChartDisplayData = () => {
|
||||
if (!salesData.value.chartData?.salesData) return []
|
||||
// 10개씩 간격으로 표시 (90일 → 9개 포인트)
|
||||
const data = salesData.value.chartData.salesData
|
||||
const step = Math.floor(data.length / 9) || 1
|
||||
return data.filter((_, index) => index % step === 0).slice(0, 9)
|
||||
}
|
||||
|
||||
const getChartLabels = () => {
|
||||
if (!salesData.value.chartData?.labels) return []
|
||||
const labels = salesData.value.chartData.labels
|
||||
const step = Math.floor(labels.length / 9) || 1
|
||||
return labels.filter((_, index) => index % step === 0).slice(0, 9)
|
||||
}
|
||||
|
||||
const getBarHeight = (amount) => {
|
||||
if (!salesData.value.chartData?.salesData) return 20
|
||||
const maxAmount = Math.max(...salesData.value.chartData.salesData)
|
||||
return Math.max(20, (amount / maxAmount) * 80) // 최소 20px, 최대 80px
|
||||
}
|
||||
|
||||
const getBarColor = (amount, index) => {
|
||||
// 변곡점이 있는 날짜는 다른 색상
|
||||
if (salesData.value.trendAnalysis?.inflectionPoints) {
|
||||
const isInflectionPoint = salesData.value.trendAnalysis.inflectionPoints.some(point => {
|
||||
const pointIndex = salesData.value.chartData?.labels.findIndex(label =>
|
||||
label === formatDate(point.date).slice(5) // MM/dd 형태로 비교
|
||||
)
|
||||
return Math.abs(pointIndex - index * Math.floor(salesData.value.chartData.salesData.length / 9)) < 2
|
||||
})
|
||||
|
||||
if (isInflectionPoint) {
|
||||
return '#FF6B6B' // 변곡점 강조 색상
|
||||
}
|
||||
}
|
||||
return '#1976D2' // 기본 색상
|
||||
}
|
||||
|
||||
// 유틸리티 함수
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return `${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 라이프사이클
|
||||
onMounted(async () => {
|
||||
console.log('SalesAnalysisView 마운트됨')
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 초기 데이터 로드
|
||||
await refreshData()
|
||||
|
||||
// Chart 초기화 (Chart.js 설치 후 주석 해제)
|
||||
// initChart()
|
||||
} catch (error) {
|
||||
console.error('매출 분석 페이지 로드 실패:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
await refreshData()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -377,6 +480,10 @@ onMounted(async () => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
height: 300px;
|
||||
display: flex;
|
||||
@ -387,9 +494,55 @@ onMounted(async () => {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.simple-chart {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chart-bars {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-around;
|
||||
height: 120px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
width: 20px;
|
||||
background-color: #1976D2;
|
||||
border-radius: 2px 2px 0 0;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chart-bar:hover {
|
||||
opacity: 0.8;
|
||||
transform: scaleY(1.1);
|
||||
}
|
||||
|
||||
.chart-labels {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.text-h4 {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
|
||||
.chart-bars {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
width: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user