This commit is contained in:
SeoJHeasdw 2025-06-17 14:04:38 +09:00
parent 0ff2f15b65
commit b5de047716
8 changed files with 1206 additions and 769 deletions

View File

@ -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] 런타임 설정 로드 완료');

View File

@ -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__에 노출됨')
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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>