first commit

This commit is contained in:
2026-07-19 03:44:35 +09:00
commit 7f950339ae
23281 changed files with 3217138 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
<script setup>
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<style>
/* Global Layout */
#app {
width: 100vw;
height: 100vh;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
import router from '../router'
const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
const apiClient = {
async request(url, options = {}) {
const token = localStorage.getItem('accessToken') || sessionStorage.getItem('accessToken')
// 기본 헤더 설정
const headers = { ...options.headers }
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json'
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const config = {
...options,
headers,
credentials: 'include', // 쿠키 전송을 위해 추가
}
try {
const fullUrl = url.startsWith('/') ? `${BASE_URL}${url}` : url
let response = await fetch(fullUrl, config)
// 401 Unauthorized인 경우 토큰 갱신 시도
if (response.status === 401) {
const refreshRes = await fetch(`${BASE_URL}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // 쿠키 전송을 위해 추가
})
if (refreshRes.ok) {
const { accessToken } = await refreshRes.json()
// 새 토큰 저장
if (localStorage.getItem('accessToken')) {
localStorage.setItem('accessToken', accessToken)
} else {
sessionStorage.setItem('accessToken', accessToken)
}
// 갱신된 토큰으로 다시 요청
headers['Authorization'] = `Bearer ${accessToken}`
response = await fetch(fullUrl, { ...config, headers })
return response
} else {
// 리프레시 토큰도 만료되었거나 유효하지 않은 경우
this.handleLogout()
return response
}
}
return response
} catch (error) {
console.error('API Request Error:', error)
throw error
}
},
handleLogout() {
localStorage.removeItem('accessToken')
sessionStorage.removeItem('accessToken')
router.push('/login')
},
get(url, options = {}) {
return this.request(url, { ...options, method: 'GET' })
},
post(url, data, options = {}) {
const isFormData = data instanceof FormData;
return this.request(url, {
...options,
method: 'POST',
body: isFormData ? data : JSON.stringify(data),
})
},
put(url, data, options = {}) {
return this.request(url, {
...options,
method: 'PUT',
body: JSON.stringify(data),
})
},
delete(url, options = {}) {
return this.request(url, { ...options, method: 'DELETE' })
}
}
export default apiClient
+54
View File
@@ -0,0 +1,54 @@
<script setup>
import { Edit3 } from 'lucide-vue-next'
defineProps({ data: Array })
</script>
<template>
<div class="card card-full blog-section">
<div class="card-header">
<h3>
<Edit3 :size="20" />블로그 발행 현황
</h3>
</div>
<div class="card-body">
<table class="data-table">
<thead>
<tr>
<th width="50" style="text-align: center !important;">순번</th>
<th width="80" style="text-align: center !important;">회차</th>
<th width="120" style="text-align: center !important;">발행 예정일</th>
<th width="100" style="text-align: center !important;">발행 유형</th>
<th style="min-width: 250px; text-align: center !important;">제목</th>
<th width="60" style="text-align: center !important;">URL</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in data" :key="idx">
<td style="text-align: center !important;">{{ idx + 1 }}</td>
<td style="text-align: center !important;"><span class="round-label">{{ item.round }}회차</span></td>
<td style="text-align: center !important;">{{ item.date }}</td>
<td style="text-align: center !important;">{{ item.type || '-' }}</td>
<td style="text-align: left !important; padding-left: 15px !important;">{{ item.title }}</td>
<td style="text-align: center !important;"><a :href="item.link" target="_blank" class="link-text">보기</a></td>
</tr>
<tr v-if="!data?.length">
<td colspan="6" class="empty" style="text-align: center !important;">발행 내역이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.link-text {
color: var(--primary);
text-decoration: underline;
}
.round-label {
font-weight: 700;
color: #64748b;
font-size: 0.85rem;
white-space: nowrap !important;
}
</style>
@@ -0,0 +1,111 @@
<script setup>
import { Info } from 'lucide-vue-next'
defineProps({ data: Object })
</script>
<template>
<div class="card card-full company-info-section">
<div class="card-header">
<h3>
<Info :size="20" />업체 상세 정보
</h3>
</div>
<div class="card-body grid-info">
<div class="info-group">
<label>회사명</label>
<p>{{ data?.name || '-' }}</p>
</div>
<div class="info-group">
<label>사업자 번호</label>
<p>{{ data?.businessNumber || '-' }}</p>
</div>
<div class="info-group">
<label>담당자</label>
<p>{{ data?.manager || '-' }}</p>
</div>
<div class="info-group">
<label>연락처</label>
<p>{{ data?.contact || '-' }}</p>
</div>
<div class="info-group full-width">
<label>블로그 링크</label>
<p><a :href="data?.blogLink" target="_blank">{{ data?.blogLink || '-' }}</a></p>
</div>
</div>
</div>
</template>
<style scoped>
.grid-info {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.info-group label {
display: block;
font-size: 0.8rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.info-group p {
font-weight: 600;
color: #1e293b;
}
.full-width {
grid-column: span 2;
}
.license-img-view {
margin-top: 0.5rem;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e2e8f0;
}
.license-img-view img {
width: 100%;
max-width: 400px;
display: block;
}
.transfer-info-embedded {
background: #f1f5f9;
padding: 1.25rem;
border-radius: 12px;
margin-bottom: 1.5rem;
}
.transfer-info-embedded h4 {
font-size: 0.9rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.divider {
height: 1px;
background: #e2e8f0;
margin: 1.5rem 0;
}
.highlight-text {
color: var(--primary) !important;
font-size: 1.1rem;
font-weight: 800 !important;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,661 @@
<script setup>
import { ref, watch, computed, onMounted } from 'vue'
import {
Save, FileText, Activity, Navigation, Search,
CreditCard, Layout, Info, Plus, Building2
} from 'lucide-vue-next'
import apiClient from '../api/client'
// 분리된 탭 컴포넌트 임포트
import BasicInfoTab from './company_information_modal/BasicInfoTab.vue'
import DepositTab from './company_information_modal/DepositTab.vue'
import BlogTab from './company_information_modal/BlogTab.vue'
import VisitorTab from './company_information_modal/VisitorTab.vue'
import MarketingTab from './company_information_modal/MarketingTab.vue'
import OurInfoTab from './company_information_modal/OurInfoTab.vue'
const props = defineProps({
companies: Array,
initialCompanyId: String
})
const emit = defineEmits(['refresh'])
const loading = ref(false)
const selectedCompanyId = ref(props.initialCompanyId || '')
const searchQuery = ref('')
const isDropdownVisible = ref(false)
const activeTab = ref('basic')
const visitorTabRef = ref(null)
const formData = ref({
name: '',
companyInfo: {
manager: '',
contact: '',
email: '',
businessNumber: '',
businessLicenseImg: '',
taxInvoice: '',
twoFactorAuth: '',
naverIdPw: '',
blogLink: '',
refLink: ''
},
visitorData: { chart: [], table: [], viewMode: 'all', startDate: '', endDate: '' },
viewData: { chart: [], table: [], viewMode: 'all', startDate: '', endDate: '' },
inflowData: { chart: [], table: [], description: '' },
exposureStatus: [],
nextMonthPlan: '',
comparisonData: { '방문자수': '', '조회수': '', '유입변화': '' },
diagnosisResult: '',
reportSummary: '',
transferInfo: {
companyName: '(주)이지엠앤씨',
businessNumber: '210-81-78652',
ceo: '김용수',
ourManager: '박민준, 유경미',
bankName: '우리은행',
account: '1005-704-557550',
holder: '(주)이지엠앤씨',
amount: ''
},
depositDetails: { dates: [], table: [] },
blogStatus: [],
blogRounds: [{ number: 1, completed: false }],
blogReportRound: 1,
serviceCategories: []
})
const selectedBlogRound = ref('전체')
const filteredBlogStatus = computed(() => {
let list = []
if (selectedBlogRound.value === '전체') {
list = formData.value.blogStatus
} else {
list = formData.value.blogStatus.filter(item => item.round === parseInt(selectedBlogRound.value))
}
// 회차별로 정렬하여 반환
return [...list].sort((a, b) => (parseInt(a.round) || 0) - (parseInt(b.round) || 0))
})
// 업체별 상세 입금 관리 데이터
const depositManagement = ref({
summary: [{ service: '', totalAmount: '', rounds: '', completedAmount: '' }],
details: [{ round: '', status: '', method: '', status2: '', subAmount: '' }],
issue: [{ target: '', completed: '', remaining: '', status: '', note: '', lastDate: '' }]
})
// 검색 필터링된 업체 목록
const filteredCompanies = computed(() => {
if (!searchQuery.value) return []
return props.companies.filter(c => c.name.toLowerCase().includes(searchQuery.value.toLowerCase()))
})
// 업체 선택 시 데이터 로드
const selectCompany = async (company) => {
selectedCompanyId.value = company.companyId
searchQuery.value = company.name
isDropdownVisible.value = false
await loadCompanyData(company.companyId)
}
const loadCompanyData = async (companyId) => {
if (!companyId) return
loading.value = true
try {
const res = await apiClient.get(`/api/report/${companyId}`)
if (res.ok) {
const data = await res.json()
const visitorTable = data.visitorData?.table || []
formData.value = {
name: data.name || '',
companyInfo: data.companyInfo || {
manager: '',
contact: '',
email: '',
businessNumber: '',
businessLicenseImg: '',
taxInvoice: '',
twoFactorAuth: '',
naverIdPw: '',
blogLink: '',
refLink: ''
},
visitorData: { ...data.visitorData, table: visitorTable },
viewData: data.viewData || { chart: [], table: [], viewMode: 'all', startDate: '', endDate: '' },
inflowData: data.inflowData || { chart: [], table: [], description: '' },
exposureStatus: data.exposureStatus || [],
nextMonthPlan: data.nextMonthPlan || '',
comparisonData: (data.comparisonData && Object.keys(data.comparisonData).length > 0) ? data.comparisonData : { '방문자수': '', '조회수': '', '유입변화': '' },
diagnosisResult: data.diagnosisResult || '',
reportSummary: data.reportSummary || '',
transferInfo: data.transferInfo || { companyName: '(주)이지엠앤씨', businessNumber: '210-81-78652', ceo: '김용수', ourManager: '박민준, 유경미', bankName: '우리은행', account: '1005-704-557550', holder: '(주)이지엠앤씨', amount: '' },
depositDetails: data.depositDetails || { dates: [], table: [] },
blogStatus: data.blogStatus || [],
blogRounds: data.blogRounds || [{ number: 1, completed: false }],
blogReportRound: data.blogReportRound || 1,
serviceCategories: data.serviceCategories || []
}
localStorage.setItem('lastManagedCompanyId', String(companyId))
if (data.depositManagement) {
depositManagement.value = data.depositManagement
} else {
depositManagement.value = { details: [], summary: [] }
}
// 회차 데이터 동기화 (기존 데이터에 높은 회차가 있으면 blogRounds에 반영)
const existingRoundsInStatus = formData.value.blogStatus.map(s => parseInt(s.round)).filter(r => !isNaN(r))
const maxExistingRound = existingRoundsInStatus.length > 0 ? Math.max(...existingRoundsInStatus) : 1
const currentRoundList = formData.value.blogRounds.map(r => r.number)
const maxInList = currentRoundList.length > 0 ? Math.max(...currentRoundList) : 1
const finalMax = Math.max(maxExistingRound, maxInList)
if (finalMax > maxInList) {
for (let i = maxInList + 1; i <= finalMax; i++) {
formData.value.blogRounds.push({ number: i, completed: false })
}
}
// 비동기적으로 통계 재계산 (컴포넌트 로드 후)
setTimeout(() => {
if (visitorTabRef.value?.calculateAllStats) {
visitorTabRef.value.calculateAllStats()
}
}, 50)
}
} catch (err) {
console.error('Data load error:', err)
} finally {
loading.value = false
}
}
const handleSave = async () => {
if (!selectedCompanyId.value) {
alert('업체를 선택해 주세요.')
return
}
loading.value = true
try {
// 유입 경로 표 날짜순 내림차순 정렬 (최신순)
if (formData.value.inflowData?.table?.length > 0) {
formData.value.inflowData.table.sort((a, b) => {
const dateA = a.month || ''
const dateB = b.month || ''
return dateB.localeCompare(dateA)
})
}
// 차트 데이터 자동 생성
formData.value.visitorData.chart = formData.value.visitorData.table
.filter(item => item.value !== undefined && item.value !== '')
.map(item => ({
date: item.date,
value: parseInt(String(item.value).replace(/,/g, '')) || 0
}))
formData.value.viewData.table = formData.value.visitorData.table
.filter(item => item.viewValue !== undefined && item.viewValue !== '')
.map(item => ({
selected: item.selected,
date: item.date,
value: item.viewValue,
diff: item.viewDiff,
isUp: item.viewIsUp
}))
formData.value.viewData.chart = formData.value.viewData.table
.map(item => ({
date: item.date,
value: parseInt(String(item.value).replace(/,/g, '')) || 0
}))
const payload = {
companyId: selectedCompanyId.value,
...formData.value,
depositManagement: depositManagement.value
}
const res = await apiClient.post('/api/report', payload)
if (res.ok) {
alert('모든 정보가 성공적으로 저장되었습니다.')
} else {
alert('저장 실패')
}
} catch (err) {
console.error('Save error:', err)
alert('저장 중 오류가 발생했습니다.')
} finally {
loading.value = false
}
}
const handleDelete = async () => {
if (!selectedCompanyId.value) return
if (!confirm(`'${formData.value.name || '선택된'}' 업체를 정말 삭제하시겠습니까?`)) return
loading.value = true
try {
const res = await apiClient.delete(`/api/report/${selectedCompanyId.value}`)
if (res.ok) {
alert('성공적으로 삭제되었습니다.')
selectedCompanyId.value = ''
searchQuery.value = ''
emit('refresh')
}
} finally {
loading.value = false
}
}
const handleAddNewCompany = async () => {
const name = prompt('추가할 새로운 업체명을 입력하세요:')
if (!name || !name.trim()) return
try {
const res = await apiClient.post('/api/reports/add', { name: name.trim() })
if (res.ok) {
alert(`'${name}' 업체가 성공적으로 추가되었습니다.`)
emit('refresh')
}
} catch (err) {
console.error('Add company error:', err)
}
}
const addRow = (list, template) => {
if (Array.isArray(list)) {
const newItem = { ...template }
if (list === formData.value.blogStatus && selectedBlogRound.value !== '전체') {
newItem.round = parseInt(selectedBlogRound.value)
}
list.push(newItem)
}
}
const removeRow = (list, idx) => { if (list.length > 0) list.splice(idx, 1) }
const formatNumber = (val) => {
if (!val) return ''
const num = String(val).replace(/[^0-9]/g, '')
return num ? num.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : ''
}
const addNextRound = () => {
const nextNum = formData.value.blogRounds.length + 1
formData.value.blogRounds.push({ number: nextNum, completed: false })
selectedBlogRound.value = String(nextNum)
}
const completeCurrentRound = () => {
const roundNum = parseInt(selectedBlogRound.value)
if (isNaN(roundNum)) return
const round = formData.value.blogRounds.find(r => r.number === roundNum)
if (round) {
round.completed = true
alert(`${roundNum}회차가 완료 처리되었습니다.`)
}
}
const deleteRound = (roundNum) => {
if (!confirm(`${roundNum}회차를 삭제하시겠습니까? 해당 회차의 모든 내역(블로그/발행현황)이 함께 삭제됩니다.`)) return
// 1. 해당 회차의 블로그 포스트 삭제
formData.value.blogStatus = formData.value.blogStatus.filter(item => item.round !== roundNum)
// 2. 해당 회차의 발행 현황 관리 데이터 삭제
depositManagement.value.issue = depositManagement.value.issue.filter(item => item.round !== roundNum)
// 3. 회차 목록에서 제거
formData.value.blogRounds = formData.value.blogRounds.filter(r => r.number !== roundNum)
// 4. 선택 상태 변경
selectedBlogRound.value = '전체'
}
onMounted(() => {
const savedTab = localStorage.getItem('lastManagedTab')
if (savedTab) activeTab.value = savedTab
let targetId = selectedCompanyId.value || localStorage.getItem('lastManagedCompanyId')
if (targetId) {
const company = props.companies.find(c => String(c.companyId) === String(targetId))
if (company) {
selectedCompanyId.value = company.companyId
searchQuery.value = company.name
loadCompanyData(targetId)
}
}
})
watch(activeTab, (newTab) => {
localStorage.setItem('lastManagedTab', newTab)
})
</script>
<template>
<div class="management-hub">
<header class="hub-header">
<div class="search-selection">
<div class="search-box">
<div class="search-input-wrapper">
<Search :size="18" class="search-icon" />
<input v-model="searchQuery" placeholder="업체명을 검색하거나 선택하세요" @focus="isDropdownVisible = true"
class="hub-search-input">
</div>
<div v-if="isDropdownVisible && filteredCompanies.length > 0" class="search-dropdown">
<div v-for="c in filteredCompanies" :key="c.companyId" class="search-item" @click="selectCompany(c)">
{{ c.name }}
</div>
</div>
</div>
<select :value="selectedCompanyId" @change="e => {
const company = props.companies.find(c => c.companyId === e.target.value);
if (company) selectCompany(company);
}" class="hub-select">
<option value="" disabled>-- 업체 선택 --</option>
<option v-for="c in companies" :key="c.companyId" :value="c.companyId">
{{ c.name }}
</option>
</select>
</div>
<div class="header-actions">
<button class="btn-hub-delete" @click="handleDelete" :disabled="loading || !selectedCompanyId">
업체 삭제
</button>
<button class="btn-hub-add" @click="handleAddNewCompany" :disabled="loading">
<Plus :size="18" /> 업체 추가
</button>
<button class="btn-save-all" @click="handleSave" :disabled="loading || !selectedCompanyId">
<Save :size="18" /> 전체 저장
</button>
</div>
</header>
<div v-if="!selectedCompanyId" class="empty-state">
<div class="empty-content">
<Building2 :size="48" style="margin: 0 auto 10px;" />
<p>관리할 업체를 선택해 주세요.</p>
</div>
</div>
<div v-else class="hub-container">
<nav class="hub-tabs">
<button :class="{ active: activeTab === 'basic' }" @click="activeTab = 'basic'">
<Info :size="16" /> 기본정보
</button>
<button :class="{ active: activeTab === 'deposit' }" @click="activeTab = 'deposit'">
<CreditCard :size="16" /> 입금/결제 관리
</button>
<button :class="{ active: activeTab === 'blog' }" @click="activeTab = 'blog'">
<Layout :size="16" /> 블로그 현황
</button>
<button :class="{ active: activeTab === 'visitor' }" @click="activeTab = 'visitor'">
<Activity :size="16" /> 방문자 데이터
</button>
<button :class="{ active: activeTab === 'marketing' }" @click="activeTab = 'marketing'">
<Search :size="16" /> 노출/유입/계획
</button>
<button :class="{ active: activeTab === 'our-info' }" @click="activeTab = 'our-info'">
<FileText :size="16" /> 이체(우리회사)
</button>
</nav>
<div class="tab-content-area">
<BasicInfoTab v-if="activeTab === 'basic'" :formData="formData" :loading="loading" />
<DepositTab v-if="activeTab === 'deposit'"
:formData="formData"
:depositManagement="depositManagement"
:selectedBlogRound="selectedBlogRound"
:filteredBlogStatus="filteredBlogStatus"
:addRow="addRow"
:removeRow="removeRow"
:formatNumber="formatNumber"
:completeCurrentRound="completeCurrentRound"
:addNextRound="addNextRound"
:deleteRound="deleteRound"
@update:selectedBlogRound="val => selectedBlogRound = val"
@save="handleSave"
/> <BlogTab v-if="activeTab === 'blog'" :formData="formData" :addRow="addRow" :removeRow="removeRow" />
<VisitorTab v-if="activeTab === 'visitor'" ref="visitorTabRef" :formData="formData" :removeRow="removeRow" />
<MarketingTab v-if="activeTab === 'marketing'" :formData="formData" :addRow="addRow" :removeRow="removeRow" />
<OurInfoTab v-if="activeTab === 'our-info'" :formData="formData" />
</div>
</div>
</div>
</template>
<style scoped>
.management-hub {
display: flex;
flex-direction: column;
height: 100%;
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.hub-header {
padding: 1.5rem;
border-bottom: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
background: #fdfdfe;
}
.search-selection {
display: flex;
gap: 15px;
align-items: center;
}
.search-box {
position: relative;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 12px;
color: #94a3b8;
}
.hub-search-input {
padding: 10px 15px 10px 40px;
border: 1px solid #e2e8f0;
border-radius: 10px;
width: 300px;
outline: none;
transition: all 0.2s;
}
.hub-search-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
}
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
margin-top: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
z-index: 100;
max-height: 250px;
overflow-y: auto;
}
.search-item {
padding: 12px 15px;
cursor: pointer;
transition: background 0.2s;
}
.search-item:hover {
background: #f8fafc;
color: var(--primary);
}
.hub-select {
padding: 10px 15px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: white;
outline: none;
cursor: pointer;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.btn-hub-delete {
padding: 10px 18px;
background: #fff;
color: #ef4444;
border: 1px solid #fecaca;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-hub-delete:hover:not(:disabled) {
background: #fef2f2;
}
.btn-hub-delete:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-hub-add {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
background: #fff;
color: #2563eb;
border: 1px solid #bfdbfe;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-hub-add:hover:not(:disabled) {
background: #eff6ff;
}
.btn-hub-add:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-save-all {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
background: var(--primary);
color: white;
border: none;
border-radius: 10px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-save-all:hover:not(:disabled) {
background: #1d4ed8;
transform: translateY(-1px);
}
.btn-save-all:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
}
.empty-content {
text-align: center;
}
.hub-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.hub-tabs {
display: flex;
background: #f1f5f9;
padding: 5px;
gap: 5px;
overflow-x: auto;
}
.hub-tabs button {
flex: 1;
min-width: 120px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border: none;
background: transparent;
color: #64748b;
font-weight: 600;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s;
}
.hub-tabs button.active {
background: white;
color: var(--primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.tab-content-area {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
</style>
+261
View File
@@ -0,0 +1,261 @@
<script setup>
import { Calendar, Printer, ChevronDown, Building2 } from 'lucide-vue-next'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
const props = defineProps({
companyName: {
type: String,
default: '업체를 선택하세요'
},
companyId: {
type: String,
default: ''
},
companies: {
type: Array,
default: () => []
},
reportRound: {
type: [String, Number],
default: ''
}
})
const emit = defineEmits(['select-company'])
import { ref, computed } from 'vue'
import { Search } from 'lucide-vue-next'
const isDropdownVisible = ref(false)
const searchQuery = ref('')
const today = new Date();
const formattedToday = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedDate = ref(formattedToday);
const filteredCompanies = computed(() => {
if (!searchQuery.value.trim()) return props.companies
return props.companies.filter(c => c.name.toLowerCase().includes(searchQuery.value.toLowerCase()))
})
const selectCompany = (company) => {
emit('select-company', company)
isDropdownVisible.value = false
}
const handlePrint = () => {
window.print();
};
</script>
<template>
<header class="main-header">
<div class="header-left">
<h1>{{ companyName }} - {{ reportRound ? `(${String(reportRound).replace(/회차/g, '')}회차) 보고서` : '사업분석 보고서' }}</h1>
<div class="breadcrumb"> / 보고서 / {{ companyName }} <span class="print-date">| 기준일: {{ selectedDate }}</span></div>
</div>
<div class="header-right no-print">
<!-- 업체 선택 섹션 -->
<div class="company-selector-container">
<div class="company-selector" @click="isDropdownVisible = !isDropdownVisible">
<Building2 :size="18" class="icon-company" />
<span class="selected-name">{{ companyName || '업체 선택' }}</span>
<ChevronDown :size="18" :class="{ 'rotate': isDropdownVisible }" />
</div>
<!-- 업체 선택 드롭다운 -->
<div v-if="isDropdownVisible" class="company-dropdown">
<div class="dropdown-header">
<span>업체 목록 ({{ companies.length }})</span>
</div>
<div class="input-box">
<input v-model="searchQuery" placeholder="업체 이름 검색" @click.stop focusable>
</div>
<ul class="company-list">
<li v-for="c in filteredCompanies" :key="c.companyId" @click="selectCompany(c)">
<Building2 :size="16" />
<span>{{ c.name }}</span>
</li>
<li v-if="filteredCompanies.length === 0" class="empty">업체가 없습니다</li>
</ul>
</div>
</div>
<div class="custom-date-picker">
<label for="report-date">기준일:</label>
<input type="date" id="report-date" v-model="selectedDate" class="date-input" />
</div>
<button class="btn btn-primary" @click="handlePrint">
<Printer :size="18" /> 인쇄
</button>
</div>
</header>
</template>
<style scoped>
.company-selector-container {
position: relative;
margin-right: 1.5rem;
}
.company-selector {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.company-selector:hover {
background: white;
border-color: var(--primary);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.selected-name {
font-weight: 700;
font-size: 1.1rem;
color: #1e293b;
}
.rotate {
transform: rotate(180deg);
}
.company-dropdown {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 280px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 100;
overflow: hidden;
animation: fadeInDown 0.2s ease-out;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdown-header {
padding: 0.75rem 1rem;
background: #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
font-weight: 600;
color: #64748b;
}
.btn-add-mini {
display: flex;
align-items: center;
gap: 4px;
background: var(--primary);
color: white;
border: none;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
}
.input-box {
padding: 0.75rem;
border-bottom: 1px solid #f1f5f9;
}
.input-box input {
width: 100%;
padding: 0.5rem;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 0.9rem;
}
.custom-date-picker {
display: flex;
align-items: center;
gap: 8px;
margin-right: 15px;
font-size: 0.9rem;
font-weight: 600;
color: #475569;
}
.date-input {
padding: 6px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
outline: none;
font-family: inherit;
font-size: 0.9rem;
color: #1e293b;
background: white;
cursor: pointer;
}
.date-input:focus {
border-color: var(--primary);
}
.print-date {
color: #64748b;
font-weight: 500;
margin-left: 5px;
}
.company-list {
list-style: none;
padding: 0.5rem;
max-height: 250px;
overflow-y: auto;
}
.company-list li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 8px;
cursor: pointer;
color: #475569;
transition: background 0.2s;
}
.company-list li:hover {
background: aliceblue;
color: var(--primary);
}
.empty {
text-align: center;
padding: 1rem;
color: #94a3b8;
font-size: 0.9rem;
}
@media print {
.no-print {
display: none !important;
}
}
</style>
+425
View File
@@ -0,0 +1,425 @@
<script setup>
import { ref, watch, computed } from 'vue'
import { X, FileText, Activity, Navigation, Search, CreditCard, Layout, Info, TrendingUp, Save } from 'lucide-vue-next'
import apiClient from '../api/client'
// 서브 컴포넌트 임포트
import BasicInfoReporting from './reports/BasicInfoReporting.vue'
import VisitorReporting from './reports/VisitorReporting.vue'
import InflowReporting from './reports/InflowReporting.vue'
import BlogReporting from './reports/BlogReporting.vue'
import ExposureReporting from './reports/ExposureReporting.vue'
import PlanReporting from './reports/PlanReporting.vue'
import SummaryReporting from './reports/SummaryReporting.vue'
import TransferReporting from './reports/TransferReporting.vue'
import DepositTrackingReporting from './reports/DepositTrackingReporting.vue'
const props = defineProps({
show: Boolean,
company: Object
})
const emit = defineEmits(['close', 'saved', 'deleted'])
const activeTab = ref('basic')
const formData = ref({
name: '',
companyInfo: { manager: '', contact: '', businessNumber: '', naverIdPw: '', blogLink: '', refLink: '' },
visitorData: { chart: [], table: [], startDate: '', endDate: '' },
viewData: { chart: [], table: [] },
inflowData: { chart: [], table: [], description: '' },
exposureStatus: [],
blogStatus: [],
depositDetails: { dates: '', table: [] },
transferInfo: { companyName: '', businessNumber: '', ceo: '', ourManager: '', bankName: '', account: '', holder: '', amount: '' },
comparisonData: { '방문자수': '', '조회수': '', '유입변화': '' },
diagnosisResult: '',
reportSummary: '',
nextMonthPlan: '',
blogReportRound: 1
})
const currentBlogRound = computed(() => {
const table = formData.value?.depositDetails?.table || []
let maxRound = 0
let found = false
for (const row of table) {
if ((row.service || '').includes('블로그')) {
const rNum = parseInt(String(row.round || '').replace(/[^0-9]/g, ''))
if (!isNaN(rNum) && rNum > maxRound) {
maxRound = rNum
found = true
}
}
}
return found ? maxRound : null
})
// 데이터 불러오기 및 정규화
watch(() => props.company, async (newVal) => {
if (newVal?.companyId) {
try {
const res = await apiClient.get(`/api/report/${newVal.companyId}`)
if (res.ok) {
const data = await res.json()
// 방문자 + 조회수 테이블 병합 로직 (전통적 방식 유지)
const vTable = data.visitorData?.table || []
const viewTable = data.viewData?.table || []
vTable.forEach(vRow => {
const matching = viewTable.find(viewRow => viewRow.date === vRow.date)
if (matching) {
vRow.viewValue = matching.value
vRow.viewDiff = matching.diff
vRow.viewIsUp = matching.isUp
}
})
formData.value = {
...formData.value,
...data,
name: data.name || newVal.name || '',
visitorData: { ...(data.visitorData || {}), table: vTable },
companyInfo: data.companyInfo || { manager: '', contact: '', businessNumber: '', naverIdPw: '', blogLink: '' },
diagnosisResult: data.diagnosisResult || '',
comparisonData: { '방문자수': '', '조회수': '', '유입변화': '', ...(data.comparisonData || {}) },
depositDetails: data.depositDetails || { dates: '', table: [] },
blogReportRound: data.blogReportRound || 1,
transferInfo: data.transferInfo || { companyName: '(주)이지엠앤씨', businessNumber: '210-81-78652', ceo: '김용수', ourManager: '박민준, 유경미', bankName: '우리은행', account: '1005-704-557550', holder: '(주)이지엠앤씨', amount: '' }
}
} else if (res.status === 404) {
// 데이터가 없는 경우(신규 업체 등) 기본값 유지
formData.value = {
...formData.value,
blogReportRound: 1
}
}
} catch (err) {
console.error('Data Load Error:', err)
}
}
}, { immediate: true })
const handleSave = async () => {
// 저장 시 차트 데이터 자동 생성을 위한 전처리
formData.value.visitorData.chart = formData.value.visitorData.table
.filter(item => item.date && item.value)
.map(item => ({ date: item.date, value: parseInt(String(item.value).replace(/,/g, '')) || 0 }))
formData.value.viewData.table = formData.value.visitorData.table
.filter(item => item.date && item.viewValue)
.map(item => ({ selected: item.selected, date: item.date, value: item.viewValue, diff: item.viewDiff, isUp: item.viewIsUp }))
formData.value.viewData.chart = formData.value.viewData.table
.map(item => ({ date: item.date, value: parseInt(String(item.value).replace(/,/g, '')) || 0 }))
try {
const res = await apiClient.post('/api/report', {
companyId: props.company.companyId,
...formData.value
})
if (res.ok) {
alert('성공적으로 저장되었습니다.')
emit('saved')
emit('close')
}
} catch (err) {
alert('저장 중 오류가 발생했습니다.')
}
}
const handleDelete = async () => {
if (!confirm('정말로 삭제하시겠습니까?')) return
try {
const res = await apiClient.delete(`/api/report/${props.company.companyId}`)
if (res.ok) {
alert('삭제되었습니다.')
emit('deleted')
emit('close')
}
} catch (err) {
alert('삭제 오류')
}
}
const handleAddNewCompany = async () => {
const name = prompt('신규 업체명:')
if (!name) return
try {
const res = await apiClient.post('/api/reports/add', { name })
if (res.ok) {
alert('추가되었습니다.')
emit('saved')
}
} catch (err) {
alert('추가 실패')
}
}
</script>
<template>
<div v-if="show" class="modal-backdrop">
<div class="modal-content large">
<header class="modal-header">
<div class="title-area">
<FileText class="icon-primary" />
<div class="name-edit-group">
<input v-model="formData.name" class="header-name-input" placeholder="업체명">
<span class="edit-badge">리포트 표시명</span>
</div>
</div>
<div class="header-right-actions">
<button class="btn-close" @click="emit('close')">
<X />
</button>
</div>
</header>
<div class="modal-body-layout">
<aside class="modal-sidebar">
<button :class="{ active: activeTab === 'basic' }" @click="activeTab = 'basic'">
<Info :size="18" /> 업체 상세정보
</button>
<button :class="{ active: activeTab === 'visitor' }" @click="activeTab = 'visitor'">
<Activity :size="18" /> 방문자/조회수
</button>
<button :class="{ active: activeTab === 'inflow' }" @click="activeTab = 'inflow'">
<Navigation :size="18" /> 유입 경로 분석
</button>
<button :class="{ active: activeTab === 'blog' }" @click="activeTab = 'blog'">
<Layout :size="18" /> 블로그 현황
</button>
<button :class="{ active: activeTab === 'exposure' }" @click="activeTab = 'exposure'">
<Search :size="18" /> 상위 노출 현황
</button>
<button :class="{ active: activeTab === 'plan' }" @click="activeTab = 'plan'">
<FileText :size="18" /> 차월 마케팅 계획
</button>
<button :class="{ active: activeTab === 'summary' }" @click="activeTab = 'summary'">
<TrendingUp :size="18" /> 종합 성과 분석
</button>
<button :class="{ active: activeTab === 'deposit-tracking' }" @click="activeTab = 'deposit-tracking'">
<Activity :size="18" /> 입금 내역 관리
</button>
<button :class="{ active: activeTab === 'transfer' }" @click="activeTab = 'transfer'">
<CreditCard :size="18" /> 우리 회사 정보
</button>
</aside>
<section class="tab-content">
<BasicInfoReporting v-if="activeTab === 'basic'" v-model:name="formData.name"
v-model:companyInfo="formData.companyInfo" />
<VisitorReporting v-if="activeTab === 'visitor'" v-model:visitorData="formData.visitorData" />
<InflowReporting v-if="activeTab === 'inflow'" v-model:inflowData="formData.inflowData" />
<BlogReporting v-if="activeTab === 'blog'" v-model:blogStatus="formData.blogStatus" />
<ExposureReporting v-if="activeTab === 'exposure'" v-model:exposureStatus="formData.exposureStatus" />
<PlanReporting v-if="activeTab === 'plan'" v-model:nextMonthPlan="formData.nextMonthPlan" />
<SummaryReporting v-if="activeTab === 'summary'" v-model:comparisonData="formData.comparisonData"
v-model:diagnosisResult="formData.diagnosisResult" v-model:reportSummary="formData.reportSummary"
:reportRound="currentBlogRound ? currentBlogRound + '회차' : ''" />
<DepositTrackingReporting v-if="activeTab === 'deposit-tracking'"
v-model:depositDetails="formData.depositDetails" />
<TransferReporting v-if="activeTab === 'transfer'" v-model:transferInfo="formData.transferInfo" />
</section>
</div>
<footer class="modal-footer">
<div class="footer-left-actions">
<button class="btn-footer-delete" @click="handleDelete">업체 삭제</button>
<button class="btn-footer-add" @click="handleAddNewCompany">신규 업체 추가</button>
</div>
<div class="footer-right-actions">
<button class="btn-cancel" @click="emit('close')">취소</button>
<button class="btn-save" @click="handleSave">
<Save :size="18" /> 전체 저장
</button>
</div>
</footer>
</div>
</div>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.modal-content.large {
background: white;
width: 1000px;
max-width: 95vw;
height: 85vh;
border-radius: 20px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
}
.title-area {
display: flex;
align-items: center;
gap: 10px;
}
.icon-primary {
color: var(--primary);
}
.name-edit-group {
display: flex;
align-items: center;
gap: 8px;
border-bottom: 2px solid var(--primary);
}
.header-name-input {
border: none;
font-size: 1.25rem;
font-weight: 700;
outline: none;
width: 250px;
}
.edit-badge {
font-size: 0.65rem;
background: #f1f5f9;
color: #64748b;
padding: 2px 6px;
border-radius: 4px;
}
.btn-close {
background: none;
border: none;
cursor: pointer;
color: #94a3b8;
}
.modal-body-layout {
flex: 1;
display: flex;
overflow: hidden;
}
.modal-sidebar {
width: 220px;
background: #f8fafc;
border-right: 1px solid #f1f5f9;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.modal-sidebar button {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border: none;
background: none;
border-radius: 8px;
text-align: left;
cursor: pointer;
color: #64748b;
font-weight: 500;
transition: all 0.2s;
}
.modal-sidebar button:hover {
background: #f1f5f9;
color: var(--primary);
}
.modal-sidebar button.active {
background: var(--primary);
color: white;
}
.tab-content {
flex: 1;
padding: 2.5rem;
overflow-y: auto;
background: #fff;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
background: #f8fafc;
}
.footer-left-actions,
.footer-right-actions {
display: flex;
gap: 12px;
}
.btn-footer-delete {
padding: 10px 18px;
color: #ef4444;
border: 1px solid #fecaca;
border-radius: 10px;
cursor: pointer;
background: #fff;
}
.btn-footer-add {
padding: 10px 18px;
color: #2563eb;
border: 1px solid #bfdbfe;
border-radius: 10px;
cursor: pointer;
background: #fff;
}
.btn-cancel {
padding: 10px 20px;
border: 1px solid #e2e8f0;
border-radius: 10px;
cursor: pointer;
background: #fff;
}
.btn-save {
padding: 10px 24px;
background: var(--primary);
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
</style>
@@ -0,0 +1,61 @@
<script setup>
import { Banknote, FileSpreadsheet } from 'lucide-vue-next'
defineProps({ data: Object })
const days = Array.from({ length: 31 }, (_, i) => i + 1)
</script>
<template>
<div class="card card-full deposit-section">
<div class="card-header">
<h3>
<Banknote :size="20" />입금액 상세 관리
</h3>
</div>
<div class="card-body">
<div class="deposit-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th width="80">회차</th>
<th width="120">날짜</th>
<th>항목/서비스</th>
<th width="150">입금금액</th>
<th>비고</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in data?.table" :key="idx">
<td>{{ item.round }}</td>
<td>{{ item.date }}</td>
<td>{{ item.service }}</td>
<td class="amount-cell">{{ Number(String(item.amount).replace(/,/g, '')).toLocaleString() }}</td>
<td>{{ item.note }}</td>
</tr>
<tr v-if="!data?.table?.length">
<td colspan="5" class="empty">입금 내역이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<style scoped>
.data-table th,
.data-table td {
text-align: center;
vertical-align: middle;
}
.amount-cell {
font-weight: 600;
color: var(--primary);
}
.empty {
padding: 40px 0;
color: var(--text-muted);
}
</style>
+210
View File
@@ -0,0 +1,210 @@
<script setup>
import { Search } from 'lucide-vue-next'
defineProps({
data: Array,
round: [String, Number]
})
const getFullUrl = (url) => {
if (!url) return ''
if (url.startsWith('data:') || url.startsWith('http')) return url
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
return `${baseUrl}${url}`
}
</script>
<template>
<div class="card card-full exposure-section">
<div class="card-header dark-header">
<h3>{{ round ? `(${round}회차) ` : '' }}노출 현황</h3>
</div>
<div class="card-body">
<div class="report-exposure-grid">
<div v-for="(item, idx) in data" :key="idx" class="report-exposure-card">
<div class="card-label">
<span class="label-text">키워드</span>
<span class="keyword-val">{{ item.keyword }}</span>
</div>
<div class="captured-image-box">
<img v-if="item.image" :src="getFullUrl(item.image)" alt="노출 캡쳐" class="full-img">
<div v-else class="no-img">이미지 없음</div>
</div>
</div>
</div>
<div v-if="!data?.length" class="empty-msg">노출 현황 데이터가 없습니다.</div>
</div>
</div>
</template>
<style scoped>
.dark-header {
background: #0f3453;
color: white;
padding: 15px 24px;
border-radius: 12px 12px 0 0;
margin: -24px -24px 24px -24px;
}
.dark-header h3 {
font-size: 1.1rem;
color: white !important;
}
.report-exposure-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.report-exposure-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.02);
page-break-inside: avoid;
}
.card-label {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
border-bottom: 2px solid #f1f5f9;
padding-bottom: 8px;
min-height: 28px;
}
.label-text {
position: absolute;
left: 0;
font-size: 0.8rem;
color: #64748b;
font-weight: 600;
}
.keyword-val {
font-size: 0.95rem;
font-weight: 700;
color: #1e293b;
text-align: center;
}
.captured-image-box {
width: 100%;
height: auto;
min-height: 310px;
max-height: 480px;
/* 너무 길어져서 페이지가 넘어가는 것을 방지 */
border-radius: 8px;
overflow: hidden;
background: #f8fafc;
border: 1px solid #f1f5f9;
}
.full-img {
width: 100%;
height: auto;
display: block;
object-fit: contain;
object-position: top;
}
.no-img {
padding: 40px;
text-align: center;
color: #94a3b8;
font-size: 0.9rem;
}
.empty-msg {
text-align: center;
padding: 40px;
color: #94a3b8;
}
@media print {
.exposure-section {
border: none !important;
box-shadow: none !important;
margin: 0 !important;
padding: 0 !important;
page-break-before: always;
break-before: page;
}
.report-exposure-grid {
display: block !important;
padding: 0 !important;
}
.report-exposure-card {
padding: 10px 0 !important;
border: none !important;
box-shadow: none !important;
page-break-inside: avoid !important;
break-inside: avoid !important;
}
.report-exposure-card:not(:first-child) {
page-break-before: always;
break-before: page;
}
/* Don't force a page break before the section itself, but before its content cards */
/* If you want the header on its own page, you can keep the break on every card. */
/* But usually, Title + Card 1 on the same page is better if it fits. */
/* Here we'll allow the Title to be on one page and each card to follow. */
.captured-image-box {
max-height: none !important;
min-height: 550px !important;
height: auto !important;
border: 1px solid #f1f5f9 !important;
margin-top: 10px !important;
}
.full-img {
width: 100% !important;
max-width: 100% !important;
height: auto !important;
max-height: 750px !important;
object-fit: contain !important;
display: block !important;
margin: 0 auto !important;
}
.dark-header {
background: #0f172a !important; /* Keep it dark for better section separation if preferred, or remove it */
padding: 12px 15px !important;
margin: 0 0 15px 0 !important;
border-radius: 8px !important;
color: white !important;
page-break-after: avoid !important;
break-after: avoid !important;
}
.dark-header h3 {
font-size: 1.1rem !important;
color: white !important;
font-weight: 800 !important;
margin: 0 !important;
}
.keyword-val {
font-size: 1rem !important;
font-weight: 800 !important;
color: #0f172a !important;
}
.label-text {
font-size: 0.8rem !important;
color: #64748b !important;
}
.card-label {
margin-bottom: 5px !important;
border-bottom: 1px solid #f1f5f9 !important;
}
}
</style>
+389
View File
@@ -0,0 +1,389 @@
<script setup>
import { onMounted, ref, watch, computed } from 'vue'
import { Navigation } from 'lucide-vue-next'
import Chart from 'chart.js/auto'
const props = defineProps({ data: Object })
const inflowChartCanvas = ref(null)
let chartInstance = null
const getLocalMonthStr = () => {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
const availableMonths = computed(() => {
const tableData = props.data?.table || []
const months = tableData.map(d => {
if (!d.month) return ''
const p = d.month.split('-')
return p.length >= 2 ? `${p[0]}-${p[1]}` : ''
}).filter(Boolean)
months.push(getLocalMonthStr())
return [...new Set(months)].sort().reverse()
})
const selectedMonth = ref(getLocalMonthStr())
watch(availableMonths, (newVal) => {
if (newVal.length > 0) {
// 가용한 데이터 중 가장 최신 월을 자동으로 선택
selectedMonth.value = newVal[0]
}
}, { immediate: true })
const getPrevMonth = (yyyy_mm, minus) => {
if (!yyyy_mm) return ''
let [y, m] = yyyy_mm.substring(0, 7).split('-').map(Number)
m -= minus
while (m <= 0) {
m += 12
y -= 1
}
return `${y}-${String(m).padStart(2, '0')}`
}
const formatMonthLabel = (yyyy_mm) => {
if (!yyyy_mm) return ''
const m = parseInt(yyyy_mm.split('-')[1], 10)
return `${m}`
}
const prev1MonthStr = computed(() => selectedMonth.value ? getPrevMonth(selectedMonth.value, 1) : null)
const prev2MonthStr = computed(() => selectedMonth.value ? getPrevMonth(selectedMonth.value, 2) : null)
const prev1Label = computed(() => formatMonthLabel(prev1MonthStr.value))
const prev2Label = computed(() => formatMonthLabel(prev2MonthStr.value))
const curLabel = computed(() => formatMonthLabel(selectedMonth.value))
const processedTableData = computed(() => {
const tableData = props.data?.table || []
let baseData = []
if (selectedMonth.value) {
baseData = tableData.filter(d => (d.month || '').startsWith(selectedMonth.value))
} else {
baseData = tableData.filter(d => !d.month)
if (baseData.length === 0) baseData = tableData
}
const prev1DataFull = prev1MonthStr.value ? tableData.filter(d => (d.month || '').startsWith(prev1MonthStr.value)) : []
const prev2DataFull = prev2MonthStr.value ? tableData.filter(d => (d.month || '').startsWith(prev2MonthStr.value)) : []
// 공백 제거된 유니크한 라벨 목록 추출
const combinedLabels = new Set([
...baseData.map(d => (d.label || '').trim()),
...prev1DataFull.map(d => (d.label || '').trim()),
...prev2DataFull.map(d => (d.label || '').trim())
].filter(l => l && l !== '미지정'))
// 라벨별로 당월, 전월, 전전월 데이터를 합산(Aggregate)
const getLabelSum = (dataList, label) => {
return dataList
.filter(d => (d.label || '').trim() === label)
.reduce((sum, d) => sum + (parseFloat(String(d.sum || d.value).replace(/[^0-9.]/g, '')) || 0), 0)
}
return Array.from(combinedLabels).map(label => {
const numericVal = getLabelSum(baseData, label)
const p1Val = getLabelSum(prev1DataFull, label)
const p2Val = getLabelSum(prev2DataFull, label)
// 전월 대비 자동 계산: (당월 합계 - 전월 합계)
const diffVal = (baseData.some(d => (d.label || '').trim() === label) && prev1DataFull.some(d => (d.label || '').trim() === label))
? (numericVal - p1Val) : null
// 특정 월에 데이터가 아예 없는 경우 '-' 표시 처리
const hasCur = baseData.some(d => (d.label || '').trim() === label)
const hasP1 = prev1DataFull.some(d => (d.label || '').trim() === label)
const hasP2 = prev2DataFull.some(d => (d.label || '').trim() === label)
const diffStr = diffVal !== null ? `${diffVal > 0 ? '+' : ''}${diffVal.toFixed(2)}%` : '-'
return {
label: label,
value: hasCur ? numericVal.toFixed(2) : '-',
numericValue: numericVal,
prev1ValueStr: hasP1 ? p1Val.toFixed(2) : '-',
prev2ValueStr: hasP2 ? p2Val.toFixed(2) : '-',
prev1Numeric: p1Val,
prev2Numeric: p2Val,
diff: diffStr,
isUp: diffVal !== null ? diffVal >= 0 : true,
isOther: label === '기타'
}
}).sort((a, b) => b.numericValue - a.numericValue)
})
const initChart = () => {
if (chartInstance) chartInstance.destroy()
if (!inflowChartCanvas.value) return
const ctx = inflowChartCanvas.value.getContext('2d')
// 전체 리스트 데이터 사용 (기존의 6개 제한 및 기타 합산 로직 제거)
const allData = processedTableData.value.filter(d => d.label !== '미지정')
const finalLabels = allData.map(d => d.label)
const finalValues = allData.map(d => d.numericValue)
const finalPrev1Values = allData.map(d => d.prev1Numeric)
const finalPrev2Values = allData.map(d => d.prev2Numeric)
// 모든 데이터셋에서 최대값 찾기
const allValues = [...finalValues, ...finalPrev1Values, ...finalPrev2Values]
const maxValue = Math.max(...allValues, 0)
// 최대값보다 약 10% 여유를 두어 Y축 설정 (최소 10% 기준)
const yMax = Math.min(100, Math.ceil((maxValue + 10) / 10) * 10)
chartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: finalLabels,
datasets: [
{
label: prev2Label.value || '전전월 (%)',
data: finalPrev2Values,
backgroundColor: '#cbd5e1', // 연한 그레이 블루
borderRadius: 4,
barThickness: 25,
},
{
label: prev1Label.value || '전월 (%)',
data: finalPrev1Values,
backgroundColor: '#94a3b8', // 중간 그레이 블루
borderRadius: 4,
barThickness: 25,
},
{
label: curLabel.value || '당월 (%)',
data: finalValues,
backgroundColor: '#2563eb', // 선명한 블루
borderRadius: 4,
barThickness: 25,
order: 0
}
].reverse()
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
bottom: 20,
top: 10
}
},
barPercentage: 0.9,
categoryPercentage: 0.8,
scales: {
x: {
grid: { display: false },
ticks: { font: { size: 10, weight: '600' } }
},
y: {
beginAtZero: true,
max: yMax,
grid: { color: '#f1f5f9' },
ticks: {
callback: (val) => val + '%',
font: { size: 10 },
stepSize: 10
}
}
},
plugins: {
legend: {
display: true,
position: 'top',
align: 'end',
labels: {
boxWidth: 10,
usePointStyle: true,
pointStyle: 'circle',
font: { size: 10, weight: '600' },
padding: 10
}
},
tooltip: {
backgroundColor: '#0f172a',
padding: 12,
callbacks: {
label: (context) => ` ${context.dataset.label}: ${context.raw}%`
}
}
}
}
})
}
onMounted(initChart)
watch(() => props.data, initChart, { deep: true })
watch(selectedMonth, initChart)
</script>
<template>
<div class="card card-full inflow-section">
<div class="card-header border-none">
<h3 class="text-xl font-bold">유입 분석</h3>
</div>
<div class="card-body pt-0" style="overflow: visible !important; padding-bottom: 0 !important;">
<div class="chart-container-bar main-bar"
style="height: 450px !important; min-height: 450px !important; position: relative !important; overflow: visible !important; margin-bottom: 0 !important;">
<canvas ref="inflowChartCanvas"></canvas>
</div>
<div class="table-container full-width" style="position: relative; z-index: 10;">
<table class="data-table">
<thead>
<tr>
<th width="80">순번</th>
<th>경로</th>
<th v-if="prev2Label">{{ prev2Label }}</th>
<th v-if="prev1Label">{{ prev1Label }}</th>
<th>{{ curLabel || '수치' }}</th>
<th>전월 대비</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in processedTableData" :key="idx">
<td>{{ idx + 1 }}</td>
<td>{{ item.label }}</td>
<td v-if="prev2Label">{{ item.prev2ValueStr }}</td>
<td v-if="prev1Label">{{ item.prev1ValueStr }}</td>
<td>{{ item.value }}</td>
<td :class="String(item.diff).includes('-') ? 'text-down' : 'text-up'">{{ item.diff }}</td>
</tr>
<tr v-if="!processedTableData.length">
<td :colspan="prev2Label && prev1Label ? 6 : 4" class="empty">선택된 월의 데이터가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
<div class="description-footer mt-8 no-print" v-if="data?.description">
<p>{{ data.description }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.inflow-section {
display: flex !important;
flex-direction: column;
}
.chart-container-bar.main-bar {
position: relative !important;
width: 100% !important;
height: 400px !important;
min-height: 400px !important;
margin-bottom: 0;
overflow: visible !important;
}
.table-container.full-width {
width: 100%;
margin-top: -10px !important;
}
/* 유입분석 표 간격 */
.data-table th {
padding: 10px 12px !important;
font-size: 0.85rem !important;
text-align: center !important;
}
.data-table td {
padding: 8px 12px !important;
font-size: 0.9rem !important;
text-align: center !important;
}
.description-footer {
padding: 1.5rem;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 12px;
width: 100%;
font-size: 1rem;
color: #475569;
line-height: 1.7;
}
.text-xl {
font-size: 1.25rem;
}
.font-bold {
font-weight: 700;
}
.border-none {
border: none !important;
}
.mb-0 {
margin-bottom: 0 !important;
}
.pb-0 {
padding-bottom: 0 !important;
}
.pt-2 {
padding-top: 0.5rem !important;
}
.mt-6 {
margin-top: 1.5rem !important;
}
.mt-8 {
margin-top: 2rem !important;
}
@media print {
.inflow-section .card-body {
padding-top: 0 !important;
padding-bottom: 0.5rem !important;
}
.chart-container-bar.main-bar {
height: 220px !important;
}
.table-container.full-width {
padding-top: 0 !important;
margin-top: -120px !important;
}
.no-print {
display: none !important;
}
}
.empty {
text-align: center;
padding: 2rem;
color: #94a3b8;
}
.month-select {
padding: 6px 12px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background-color: white;
font-size: 0.95rem;
font-family: inherit;
font-weight: 500;
color: #334155;
cursor: pointer;
outline: none;
}
.month-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
</style>
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
<script setup>
import { ClipboardList } from 'lucide-vue-next'
defineProps({
data: String,
round: [String, Number]
})
</script>
<template>
<div class="card card-full plan-section">
<div class="card-header">
<h3>
<ClipboardList :size="20" />{{ round ? `(${round}회차) ` : '' }}진행방향
</h3>
</div>
<div class="card-body">
<div class="plan-display-box">
{{ data || '등록된 계획이 없습니다.' }}
</div>
</div>
</div>
</template>
<style scoped>
.plan-display-box {
min-height: 120px;
padding: 1rem;
background: #f8fafc;
border-radius: 8px;
line-height: 1.7;
white-space: pre-wrap;
font-size: 0.95rem;
color: #334155;
}
</style>
+492
View File
@@ -0,0 +1,492 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { BarChart3, Wallet, LayoutDashboard, BookOpen, FileText, Users, LogOut, Plus, Building2, MessageSquare, Info, Settings, X, Shield } from 'lucide-vue-next'
import apiClient from '../api/client'
const router = useRouter()
const props = defineProps({
companies: Array
})
const emit = defineEmits(['select', 'edit', 'refresh', 'changeView', 'logout'])
const showInput = ref(false)
const newCompanyName = ref('')
const showAISettings = ref(false)
const aiConfig = ref({
geminiKey: localStorage.getItem('ai_gemini_key') || '',
gptKey: localStorage.getItem('ai_gpt_key') || '',
preferredProvider: localStorage.getItem('ai_preferred_provider') || 'gemini'
})
const saveAISettings = () => {
localStorage.setItem('ai_gemini_key', aiConfig.value.geminiKey)
localStorage.setItem('ai_gpt_key', aiConfig.value.gptKey)
localStorage.setItem('ai_preferred_provider', aiConfig.value.preferredProvider)
showAISettings.value = false
alert('AI 설정이 저장되었습니다.')
}
const goToAIChat = () => {
if (router.currentRoute.value.path !== '/ai-chat') {
router.push('/ai-chat')
}
}
const goToAdmin = () => {
if (router.currentRoute.value.path !== '/admin') {
router.push('/admin')
}
}
const handleAddCompany = async () => {
if (!newCompanyName.value.trim()) return
try {
const res = await apiClient.post('/api/companies', { name: newCompanyName.value })
if (res.ok) {
newCompanyName.value = ''
showInput.value = false
emit('refresh') // 목록 갱신 요청
alert('업체가 등록되었습니다.')
}
} catch (err) {
alert('등록 실패')
}
}
</script>
<template>
<aside class="sidebar">
<div class="sidebar-header">
<h2>관리 Tool</h2>
<!-- <div class="user-info">
<div class="avatar">W</div>
<span>강촌상사 리포트</span>
</div> -->
</div>
<nav class="sidebar-nav">
<!-- 1. 보고서 (단일 메뉴) -->
<div class="nav-group clickable-group" @click="emit('changeView', 'dashboard')">
<h3>
<LayoutDashboard :size="14" /> 보고서
</h3>
</div>
<div class="sidebar-divider"></div>
<!-- 2. 입금액 상세정리 -->
<div class="nav-group">
<h3>
<Wallet :size="14" /> 입금액 상세정리
</h3>
<ul>
<li @click="emit('changeView', 'deposit-all')">
<span class="dot"></span> 년도별 전체 (월별)
</li>
<li @click="emit('changeView', 'deposit-kmong')">
<span class="dot"></span> 크몽 입금액
</li>
</ul>
</div>
<!-- 4. 업체정보 -->
<div class="nav-group">
<h3 @click="emit('changeView', 'company-total')">
<Info :size="14" /> 업체정보
</h3>
</div>
</nav>
<div class="sidebar-footer">
<button class="btn-config" @click="showAISettings = true">
<Settings :size="18" /> AI 설정
</button>
<button class="btn-ai" @click="goToAIChat">
<MessageSquare :size="18" /> AI 모드
</button>
<button class="btn-admin" @click="goToAdmin">
<Shield :size="18" /> 관리자
</button>
<button class="btn-logout" @click="emit('logout')">
<LogOut :size="18" /> 로그아웃
</button>
</div>
<!-- AI 설정 모달 -->
<div v-if="showAISettings" class="settings-modal-overlay" @click.self="showAISettings = false">
<div class="settings-modal">
<div class="modal-header">
<h3>AI 연동 설정</h3>
<button class="btn-close" @click="showAISettings = false">
<X :size="20" />
</button>
</div>
<div class="modal-body">
<div class="input-group">
<label>주요 사용 AI</label>
<select v-model="aiConfig.preferredProvider" class="config-select">
<option value="gemini">Google Gemini</option>
<option value="gpt">OpenAI GPT-4</option>
</select>
</div>
<div class="input-group">
<label>Gemini API Key</label>
<input v-model="aiConfig.geminiKey" type="password" placeholder="AIzaSy...">
</div>
<div class="input-group">
<label>GPT API Key</label>
<input v-model="aiConfig.gptKey" type="password" placeholder="sk-...">
</div>
<p class="config-hint">* 키를 입력하지 않으면 서버의 기본 키를 사용합니다.</p>
</div>
<div class="modal-footer">
<button class="btn-save-config" @click="saveAISettings">설정 저장</button>
</div>
</div>
</div>
</aside>
</template>
<style scoped>
.sidebar-footer {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.btn-config {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.05);
color: #94a3b8;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-config:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* Settings Modal Styles */
.settings-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.settings-modal {
background: #1e293b;
width: 100%;
max-width: 400px;
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.modal-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
font-size: 1.1rem;
color: #fff;
margin: 0;
}
.btn-close {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 4px;
}
.modal-body {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.config-select {
width: 100%;
padding: 10px;
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #fff;
font-size: 0.9rem;
}
.modal-body input {
width: 100%;
padding: 10px 12px;
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #fff;
font-size: 0.9rem;
}
.config-hint {
font-size: 0.75rem;
color: #64748b;
margin-top: -0.5rem;
}
.modal-footer {
padding: 1.25rem 1.5rem;
background: rgba(0, 0, 0, 0.2);
text-align: right;
}
.btn-save-config {
background: #2563eb;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.btn-save-config:hover {
background: #1d4ed8;
}
.btn-ai {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 6px -1px rgba(99, 102, 241, 0.2);
}
.btn-ai:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px -2px rgba(99, 102, 241, 0.3);
opacity: 0.9;
}
.btn-admin {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 6px -1px rgba(14, 165, 233, 0.2);
}
.btn-admin:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px -2px rgba(14, 165, 233, 0.3);
opacity: 0.9;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-right: 0.5rem;
}
.btn-add-inline {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
}
.btn-add-inline:hover {
background: var(--primary);
border-color: var(--primary);
}
.sidebar-input-box {
padding: 0 0.5rem 0.75rem;
}
.sidebar-input-box input {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
padding: 8px;
border-radius: 6px;
font-size: 0.85rem;
}
.nav-group {
margin-bottom: 1.5rem;
}
.nav-group h3 {
font-size: 0.85rem !important;
color: #94a3b8;
display: flex !important;
align-items: center;
gap: 8px;
cursor: pointer;
}
.nav-group h3:hover,
.nav-group.clickable-group:hover h3 {
color: #fff;
}
.clickable-group {
cursor: pointer;
padding: 4px 0;
border-radius: 6px;
transition: background 0.2s;
}
.clickable-group:hover {
background: rgba(255, 255, 255, 0.05);
}
.nav-group ul {
margin-top: 0.5rem;
padding-left: 0.5rem;
}
.nav-group li {
font-size: 0.8rem !important;
color: #64748b !important;
padding: 6px 12px !important;
display: flex !important;
align-items: center;
gap: 8px;
cursor: pointer;
}
.nav-group li:hover {
color: #fff !important;
background: rgba(255, 255, 255, 0.05) !important;
}
.dot {
width: 4px;
height: 4px;
background: #475569;
border-radius: 50%;
}
.sidebar-divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 1.5rem 0;
}
.scrollable-list {
max-height: 240px;
overflow-y: auto;
margin-top: 0.5rem;
list-style: none;
padding: 0;
}
.company-list-item {
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
padding: 8px 12px !important;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
gap: 10px;
transition: background 0.2s;
}
.company-list-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.item-content {
flex: 1;
min-width: 0;
}
.truncate {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.85rem;
color: #cbd5e1;
}
.btn-edit-active {
background: var(--primary);
color: white;
border: none;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
}
.btn-edit-active:hover {
background: #1d4ed8;
}
.empty-msg {
font-size: 0.8rem;
color: #64748b;
padding: 1rem;
text-align: center;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
<script setup>
import { CreditCard, Building2, User } from 'lucide-vue-next'
defineProps({ data: Object })
const formatNumber = (val) => {
if (!val) return '0'
return String(val).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
</script>
<template>
<div class="card card-full transfer-section">
<div class="card-header">
<h3>
<CreditCard :size="20" />이체 정보
</h3>
</div>
<div class="card-body">
<div class="transfer-grid-layout">
<!-- 우리 회사 상세 정보 -->
<div class="our-company-details">
<h4>
<Building2 :size="16" /> {{ data?.companyName || '(주)이지엠앤씨' }}
</h4>
<div class="detail-rows">
<div class="detail-row">
<span class="label">사업자 번호</span>
<span class="value">{{ data?.businessNumber || '210-81-78652' }}</span>
</div>
<div class="detail-row">
<span class="label">대표</span>
<span class="value">{{ data?.ceo || '김용수' }}</span>
</div>
<div class="detail-row">
<span class="label">담당자</span>
<span class="value">{{ data?.ourManager || '박민준, 유경미' }}</span>
</div>
</div>
</div>
<div class="vertical-divider"></div>
<!-- 계좌 입금 정보 -->
<div class="bank-payment-details">
<h4>
<CreditCard :size="16" /> 입금 정보
</h4>
<div class="detail-rows">
<div class="detail-row">
<span class="label">{{ data?.bankName || '우리은행' }}</span>
<span class="value font-bold">{{ data?.account || '1005-704-557550' }}</span>
</div>
<div class="detail-row">
<span class="label">예금주</span>
<span class="value font-bold">{{ data?.holder || '(주)이지엠앤씨' }}</span>
</div>
<div class="detail-row amount-highlight">
<span class="label">입금 금액</span>
<span class="value amount">{{ formatNumber(data?.amount) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.transfer-grid-layout {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 2rem;
align-items: flex-start;
}
.vertical-divider {
width: 1px;
background: #e2e8f0;
height: 100%;
}
h4 {
font-size: 0.95rem;
font-weight: 700;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: #1e293b;
}
.detail-rows {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-row {
display: flex;
justify-content: space-between;
font-size: 0.9rem;
}
.label {
color: #64748b;
}
.value {
color: #1e293b;
font-weight: 600;
}
.font-bold {
font-weight: 800;
}
.amount-highlight {
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px dashed #e2e8f0;
}
.amount {
color: var(--primary);
font-size: 1.1rem;
font-weight: 800;
}
@media (max-width: 768px) {
.transfer-grid-layout {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.vertical-divider {
display: none;
}
}
</style>
+168
View File
@@ -0,0 +1,168 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { Eye } from 'lucide-vue-next'
import Chart from 'chart.js/auto'
const props = defineProps({ data: Object })
const viewChartCanvas = ref(null)
let chartInstance = null
const filteredTableData = computed(() => {
const vData = props.data
if (!vData?.table) return []
let list = vData.table.filter(t => t.selected !== false)
const mode = vData.viewMode || 'all'
const start = vData.startDate
const end = vData.endDate
// 1. 날짜 범위 필터링
if (start || end) {
list = list.filter(item => {
const isAfterStart = !start || item.date >= start
const isBeforeEnd = !end || item.date <= end
return isAfterStart && isBeforeEnd
})
}
// 2. 모드별 필터 (주/월 단위)
if (mode === 'week') list = list.slice(-7)
else if (mode === 'month') list = list.slice(-30)
// 3. 노출된 데이터 간의 과거 대비 수치 재계산
return list.map((item, idx) => {
const prev = list[idx - 1]
if (!prev) return { ...item, displayDiff: '-', displayIsUp: true }
const curVal = parseInt(String(item.value).replace(/,/g, ''))
const prevVal = parseInt(String(prev.value).replace(/,/g, ''))
if (isNaN(curVal) || isNaN(prevVal) || prevVal === 0) {
return { ...item, displayDiff: '-', displayIsUp: true }
}
const diffPercent = ((curVal - prevVal) / prevVal * 100).toFixed(1)
const isUp = curVal >= prevVal
const diff = (isUp ? '▲ ' : '▼ ') + Math.abs(diffPercent) + '%'
return { ...item, displayDiff: diff, displayIsUp: isUp }
})
})
const displayTableRows = computed(() => {
const data = filteredTableData.value
if (!data || data.length === 0) return []
return [...data]
})
const initChart = () => {
if (chartInstance) chartInstance.destroy()
if (!viewChartCanvas.value) return
const ctx = viewChartCanvas.value.getContext('2d')
const displayData = filteredTableData.value
const labels = displayData.map(d => d.date)
const values = displayData.map(d => parseInt(String(d.value).replace(/,/g, '')) || 0)
if (labels.length === 0) { labels.push('-'); values.push(0); }
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '조회수',
data: values,
borderColor: '#0f172a',
backgroundColor: 'rgba(15, 23, 42, 0.1)',
fill: true,
tension: 0.4,
borderWidth: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
layout: { padding: { top: 0, bottom: 0 } }
}
})
}
onMounted(initChart)
watch(() => props.data, initChart, { deep: true })
</script>
<template>
<div class="card view-section">
<div class="card-header">
<h3>
<Eye :size="20" />조회수
</h3>
</div>
<div class="card-body">
<div class="chart-container" v-show="filteredTableData.length > 0">
<canvas ref="viewChartCanvas"></canvas>
</div>
<div class="empty-chart-msg" v-if="filteredTableData.length === 0">
데이터가 충분하지 않아 차트를 불러올 없습니다.
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>날짜</th>
<th>조회수</th>
<th>전일 대비</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in displayTableRows" :key="idx">
<td class="text-center">{{ item.date || '-' }}</td>
<td class="text-center">{{ item.value || '-' }}</td>
<td class="text-center" :class="item.displayIsUp ? 'text-up' : 'text-down'">
{{ item.displayDiff || '-' }}
</td>
</tr>
<tr v-if="filteredTableData.length === 0">
<td colspan="3" class="empty-table-msg">데이터가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<style scoped>
.chart-container {
height: 350px;
margin-bottom: 1.5rem;
}
@media print {
.chart-container {
height: 240px !important;
margin-bottom: 0px !important;
}
.table-container {
margin-top: 0px !important;
}
}
.table-container {
width: 100%;
}
.empty {
text-align: center;
padding: 2rem;
color: #94a3b8;
}
.data-table th,
.data-table td {
text-align: center;
}
</style>
+169
View File
@@ -0,0 +1,169 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { Users } from 'lucide-vue-next'
import Chart from 'chart.js/auto'
const props = defineProps({ data: Object })
const visitorChartCanvas = ref(null)
let chartInstance = null
const filteredTableData = computed(() => {
const vData = props.data
if (!vData?.table) return []
let list = vData.table.filter(t => t.selected !== false)
const mode = vData.viewMode || 'all'
const start = vData.startDate
const end = vData.endDate
// 1. 날짜 범위 필터링
if (start || end) {
list = list.filter(item => {
const isAfterStart = !start || item.date >= start
const isBeforeEnd = !end || item.date <= end
return isAfterStart && isBeforeEnd
})
}
// 2. 모드별 필터 (주/월 단위)
if (mode === 'week') list = list.slice(-7)
else if (mode === 'month') list = list.slice(-30)
// 3. 노출된 데이터 간의 과거 대비 수치 재계산
return list.map((item, idx) => {
const prev = list[idx - 1]
if (!prev) return { ...item, displayDiff: '-', displayIsUp: true }
const curVal = parseInt(String(item.value).replace(/,/g, ''))
const prevVal = parseInt(String(prev.value).replace(/,/g, ''))
if (isNaN(curVal) || isNaN(prevVal) || prevVal === 0) {
return { ...item, displayDiff: '-', displayIsUp: true }
}
const diffPercent = ((curVal - prevVal) / prevVal * 100).toFixed(1)
const isUp = curVal >= prevVal
const diff = (isUp ? '▲ ' : '▼ ') + Math.abs(diffPercent) + '%'
return { ...item, displayDiff: diff, displayIsUp: isUp }
})
})
const displayTableRows = computed(() => {
const data = filteredTableData.value
if (!data || data.length === 0) return []
return [...data]
})
const initChart = () => {
if (chartInstance) chartInstance.destroy()
if (!visitorChartCanvas.value) return
const ctx = visitorChartCanvas.value.getContext('2d')
const displayData = filteredTableData.value
const labels = displayData.map(d => d.date)
const values = displayData.map(d => parseInt(String(d.value).replace(/,/g, '')) || 0)
if (labels.length === 0) { labels.push('-'); values.push(0); }
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '방문자 수',
data: values,
borderColor: '#2563eb',
backgroundColor: 'rgba(37, 99, 235, 0.1)',
fill: true,
tension: 0.4,
borderWidth: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
layout: { padding: { top: 0, bottom: 0 } }
}
})
}
onMounted(initChart)
watch(() => props.data, initChart, { deep: true })
</script>
<template>
<div class="card visitor-section">
<div class="card-header">
<h3>
<Users :size="20" />방문자
</h3>
</div>
<div class="card-body">
<div class="chart-container" v-show="filteredTableData.length > 0">
<canvas ref="visitorChartCanvas"></canvas>
</div>
<div class="empty-chart-msg" v-if="filteredTableData.length === 0">
데이터가 충분하지 않아 차트를 불러올 없습니다.
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>날짜</th>
<th>방문자 </th>
<th>전일 대비</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in displayTableRows" :key="idx">
<td class="text-center">{{ item.date || '-' }}</td>
<td class="text-center">{{ item.value || '-' }}</td>
<td class="text-center" :class="item.displayIsUp ? 'text-up' : 'text-down'">
{{ item.displayDiff || '-' }}
</td>
</tr>
<tr v-if="filteredTableData.length === 0">
<td colspan="3" class="empty-table-msg">데이터가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<style scoped>
.chart-container {
height: 350px;
margin-bottom: 1.5rem;
}
/* 인쇄 시 그래프 높이 및 여백 조정 */
@media print {
.chart-container {
height: 240px !important;
margin-bottom: 0px !important;
}
.table-container {
margin-top: 0px !important;
}
}
.table-container {
width: 100%;
}
.empty {
text-align: center;
padding: 2rem;
color: #94a3b8;
}
.data-table th,
.data-table td {
text-align: center;
}
</style>
@@ -0,0 +1,218 @@
<script setup>
import { Plus } from 'lucide-vue-next'
import apiClient from '../../api/client'
const props = defineProps({
formData: Object,
loading: Boolean
})
const triggerLicenseUpload = () => {
const el = document.getElementById('license-upload')
if (el) el.click()
}
const handleLicenseUpload = async (event) => {
const file = event.target.files[0]
if (file) {
const formData = new FormData()
formData.append('image', file)
try {
const res = await apiClient.post('/api/upload', formData)
const data = await res.json()
if (data.imageUrl) {
props.formData.companyInfo.businessLicenseImg = data.imageUrl
}
} catch (err) {
console.error('License upload failed:', err)
alert('이미지 업로드에 실패했습니다.')
}
}
}
const getFullUrl = (url) => {
if (!url) return ''
if (url.startsWith('data:') || url.startsWith('http')) return url
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
return `${baseUrl}${url}`
}
</script>
<template>
<div class="form-card">
<h3>업체 기본 상세정보</h3>
<div class="form-grid">
<div class="form-group">
<label>업체명</label>
<input v-model="formData.name" placeholder="업체명">
</div>
<div class="form-group">
<label>담당자</label>
<input v-model="formData.companyInfo.manager" placeholder="담당자 성함">
</div>
<div class="form-group">
<label>연락처</label>
<input v-model="formData.companyInfo.contact" placeholder="010-0000-0000">
</div>
<div class="form-group">
<label>사업자 번호</label>
<input v-model="formData.companyInfo.businessNumber" placeholder="000-00-00000">
</div>
<div class="from-line"></div>
<div class="form-group full">
<label>블로그 링크</label>
<input v-model="formData.companyInfo.blogLink" placeholder="https://blog.naver.com/...">
</div>
<div class="form-group">
<label>네이버 ID/PW</label>
<input v-model="formData.companyInfo.naverIdPw" placeholder="id / password">
</div>
<div class="form-group">
<label>이메일</label>
<input v-model="formData.companyInfo.email" placeholder="email@example.com">
</div>
<div class="form-group">
<label>세금계산서</label>
<select v-model="formData.companyInfo.taxInvoice" class="hub-input">
<option value="">-- 선택 --</option>
<option value="발행">발행</option>
<option value="미발행">미발행</option>
<option value="영수">영수</option>
</select>
</div>
<div class="form-group">
<label>2단계 인증</label>
<select v-model="formData.companyInfo.twoFactorAuth" class="hub-input">
<option value="">-- 선택 --</option>
<option value="있음">있음</option>
<option value="없음">없음</option>
</select>
</div>
<div class="form-group">
<label>사업자 등록증</label>
<div class="license-upload-box" @click="triggerLicenseUpload">
<img v-if="formData.companyInfo.businessLicenseImg" :src="getFullUrl(formData.companyInfo.businessLicenseImg)"
class="preview-license">
<div v-else class="license-placeholder">
<Plus :size="20" /> 이미지 첨부
</div>
<input id="license-upload" type="file" @change="handleLicenseUpload" hidden accept="image/*">
</div>
</div>
<div class="form-group">
<label>업체 메모</label>
<textarea v-model="formData.companyInfo.memo" placeholder="업체와 관련된 특이사항을 입력하세요" class="hub-textarea"></textarea>
</div>
</div>
</div>
</template>
<style scoped>
.form-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.form-card h3 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1e293b;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.from-line {
width: 100%;
border-top: 1px solid #e2e8f0;
}
.form-group.full {
grid-column: span 2;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: #64748b;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
outline: none;
}
.form-group input:focus {
border-color: var(--primary);
}
.hub-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
outline: none;
}
.hub-input:focus {
border-color: var(--primary);
}
.hub-textarea {
width: 100%;
height: 100%;
min-height: 120px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
outline: none;
resize: none;
font-family: inherit;
}
.hub-textarea:focus {
border-color: var(--primary);
}
.license-upload-box {
width: 100%;
aspect-ratio: 4/3;
background: white;
border: 1px dashed #cbd5e1;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
position: relative;
}
.preview-license {
width: 100%;
height: 100%;
object-fit: contain;
}
.license-placeholder {
font-size: 0.85rem;
color: #94a3b8;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
</style>
@@ -0,0 +1,410 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Plus } from 'lucide-vue-next'
const props = defineProps({
formData: Object,
addRow: Function,
removeRow: Function
})
const statusFilter = ref('전체')
const statusOptions = ['전체', '임시저장', '발행완료']
const roundFilter = ref('전체')
onMounted(() => {
if (props.formData.blogRounds && props.formData.blogRounds.length > 0) {
const maxRound = Math.max(...props.formData.blogRounds.map(r => r.number))
roundFilter.value = maxRound
// 보고서 노출회차의 기본값을 최신 회차로 설정
if (!props.formData.blogReportRound) {
props.formData.blogReportRound = maxRound
}
}
})
const filteredByStatus = computed(() => {
return props.formData.blogStatus.filter(item => {
const matchStatus = statusFilter.value === '전체' || item.status === statusFilter.value
const matchRound = roundFilter.value === '전체' || Number(item.round) === Number(roundFilter.value)
return matchStatus && matchRound
})
})
const autoYearEnabled = ref(true)
const handleDateBlur = (item) => {
if (!item.date) return
let val = item.date.replace(/[^0-9]/g, '')
if (autoYearEnabled.value && (val.length === 3 || val.length === 4)) {
const currentYear = new Date().getFullYear()
let m, d
if (val.length === 3) {
m = '0' + val.substring(0, 1)
d = val.substring(1, 3)
} else {
m = val.substring(0, 2)
d = val.substring(2, 4)
}
item.date = `${currentYear}.${m}.${d}`
return
}
if (val.length === 6) {
item.date = `20${val.substring(0, 2)}.${val.substring(2, 4)}.${val.substring(4, 6)}`
} else if (val.length === 8) {
item.date = `${val.substring(0, 4)}.${val.substring(4, 6)}.${val.substring(6, 8)}`
}
}
const handleTableKeydown = (e) => {
const key = e.key
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return
const input = e.target
if (['INPUT', 'TEXTAREA'].includes(input.tagName)) {
if (input.type === 'text' || input.type === 'number' || input.type === '' || input.tagName === 'TEXTAREA') {
if (key === 'ArrowLeft' && input.selectionStart > 0) return
if (key === 'ArrowRight' && input.selectionEnd < (input.value || '').length) return
// Enter의 경우 Shift+Enter가 아니면 다음 행으로 이동하도록 처리
if (key === 'Enter' && e.shiftKey) return
}
}
const td = input.closest('td')
if (!td) return
const tr = td.closest('tr')
const tbody = tr.closest('tbody')
if (!tbody) return
let colIdx = Array.from(tr.children).indexOf(td)
let rowIdx = Array.from(tbody.children).indexOf(tr)
let targetInput = null
while (!targetInput) {
if (key === 'ArrowUp') rowIdx--
else if (key === 'ArrowDown' || key === 'Enter') rowIdx++
else if (key === 'ArrowLeft') colIdx--
else if (key === 'ArrowRight') colIdx++
const targetTr = tbody.children[rowIdx]
if (!targetTr) return
const targetTd = targetTr.children[colIdx]
if (!targetTd) return
targetInput = targetTd.querySelector('input:not([type="hidden"]), select, textarea')
}
if (targetInput) {
e.preventDefault()
targetInput.focus()
if (targetInput.tagName === 'INPUT' || targetInput.tagName === 'TEXTAREA') {
targetInput.select()
}
}
}
const handleLinkUpdate = (item) => {
if (item.link && item.link.trim() !== '' && item.link !== 'https://...') {
item.status = '발행완료'
} else {
item.status = '임시저장'
}
}
</script>
<template>
<div class="form-card">
<div class="filter-area">
<div class="filter-header">
<div class="status-filters">
<button
v-for="status in statusOptions"
:key="status"
:class="['filter-btn', { active: statusFilter === status }]"
@click="statusFilter = status"
>
{{ status }}
</button>
</div>
<div class="header-btns" style="display: flex; gap: 10px; align-items: center;">
<button class="btn-toggle-year" :class="{ active: autoYearEnabled }" @click="autoYearEnabled = !autoYearEnabled">
현재년도 자동완성 {{ autoYearEnabled ? 'ON' : 'OFF' }}
</button>
<button class="btn-add-mini"
@click="addRow(formData.blogStatus, {
date: '',
title: '',
link: '',
status: '임시저장',
round: (formData.blogRounds.length > 0 ? formData.blogRounds[formData.blogRounds.length-1].number : 1)
})">
<Plus :size="14" /> 내역 추가
</button>
</div>
</div>
<div class="round-filter-row">
<label>회차별 보기</label>
<select v-model="roundFilter" class="round-select">
<option value="전체">전체 회차</option>
<option v-for="r in formData.blogRounds" :key="r.number" :value="r.number">
{{ r.number }}회차
</option>
</select>
<label style="margin-left: 30px;">보고서 노출회차</label>
<select v-model="formData.blogReportRound" class="round-select highlighted">
<option v-for="r in formData.blogRounds" :key="r.number" :value="r.number">
{{ r.number }}회차
</option>
</select>
</div>
</div>
<div class="table-container">
<div class="table-title">
<h3>블로그 발행 현황 <span v-if="statusFilter !== '전체'" class="status-tag">{{ statusFilter }}</span></h3>
</div>
<table class="hub-table" @keydown="handleTableKeydown">
<thead>
<tr>
<th width="120">날짜</th>
<th>제목</th>
<th>링크</th>
<th width="140">상태</th>
<th width="50"></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in filteredByStatus" :key="idx">
<td><input v-model="item.date" class="table-input" placeholder="MM-DD" @blur="handleDateBlur(item)"></td>
<td><textarea v-model="item.title" class="table-input table-textarea" placeholder="제목"></textarea></td>
<td><textarea v-model="item.link" class="table-input table-textarea" placeholder="https://..." @input="handleLinkUpdate(item)"></textarea></td>
<td>
<select v-model="item.status" class="table-select">
<option value="임시저장">임시저장</option>
<option value="발행완료">발행완료</option>
</select>
</td>
<td><button class="btn-row-del" @click="removeRow(formData.blogStatus, formData.blogStatus.indexOf(item))">&times;</button></td>
</tr>
<tr v-if="filteredByStatus.length === 0">
<td colspan="5" class="empty-msg">내용이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.form-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.btn-toggle-year {
padding: 6px 12px;
border: 1px solid #cbd5e1;
background: #f8fafc;
color: #64748b;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-toggle-year.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.table-title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 1.5rem;
}
.table-title h3 {
font-size: 1.1rem;
font-weight: 700;
color: #1e293b;
margin: 0;
}
.status-tag {
font-size: 0.75rem;
background: #f1f5f9;
color: #64748b;
padding: 2px 8px;
border-radius: 12px;
}
.filter-area {
margin-bottom: 2rem;
background: #f8fafc;
padding: 15px;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 15px;
}
.filter-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.round-filter-row {
display: flex;
align-items: center;
gap: 12px;
padding-top: 10px;
border-top: 1px solid #e2e8f0;
}
.round-filter-row label {
font-size: 0.85rem;
font-weight: 600;
color: #64748b;
}
.round-select {
padding: 6px 12px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: white;
font-size: 0.85rem;
color: #1e293b;
cursor: pointer;
min-width: 120px;
}
.round-select.highlighted {
border-color: var(--primary);
background-color: #f0f9ff;
font-weight: 700;
color: var(--primary);
}
.status-filters {
display: flex;
gap: 8px;
}
.filter-btn {
padding: 6px 16px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
border: 1px solid transparent;
background: transparent;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover {
background: #f1f5f9;
}
.filter-btn.active {
background: white;
color: var(--primary);
border-color: #e2e8f0;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.hub-table {
width: 100%;
border-collapse: collapse;
}
.hub-table th {
text-align: center;
padding: 12px;
background: #f8fafc;
font-size: 0.8rem;
color: #64748b;
border-bottom: 2px solid #f1f5f9;
}
.hub-table td {
padding: 8px;
border-bottom: 1px solid #f1f5f9;
text-align: center;
}
.table-input {
width: 100%;
border: none;
background: transparent;
padding: 8px;
outline: none;
text-align: center;
}
.table-input:focus {
background: #f8fafc;
}
.table-textarea {
resize: none;
min-height: 46px;
display: flex;
align-items: center;
justify-content: center;
vertical-align: middle;
padding-top: 12px;
}
.table-select {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 4px 8px;
outline: none;
font-size: 0.85rem;
background: #fff;
cursor: pointer;
}
.btn-add-mini {
background: var(--primary);
color: white;
border: none;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
font-weight: 600;
}
.btn-row-del {
color: #ef4444;
border: none;
background: none;
font-size: 1.25rem;
cursor: pointer;
}
.empty-msg {
padding: 3rem;
color: #94a3b8;
font-size: 0.9rem;
}
</style>
@@ -0,0 +1,217 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { Plus, Edit3 } from 'lucide-vue-next'
const props = defineProps({
modelValue: String,
categories: Array
})
const emit = defineEmits(['update:modelValue', 'add-category', 'remove-category'])
const isOpen = ref(false)
const newCategoryName = ref('')
const dropdownRef = ref(null)
const toggleDropdown = () => {
isOpen.value = !isOpen.value
}
const selectCategory = (name) => {
emit('update:modelValue', name)
isOpen.value = false
}
const addNewCategory = () => {
if (newCategoryName.value.trim()) {
emit('add-category', newCategoryName.value.trim())
emit('update:modelValue', newCategoryName.value.trim())
newCategoryName.value = ''
}
}
const removeCategory = (name) => {
emit('remove-category', name)
}
const handleClickOutside = (event) => {
if (dropdownRef.value && !dropdownRef.value.contains(event.target)) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
const getCategoryColor = (name) => {
const cat = props.categories.find(c => c.name === name)
return cat ? cat.color : '#64748b'
}
</script>
<template>
<div class="category-select-container" ref="dropdownRef">
<div class="selected-box" @click="toggleDropdown">
<span v-if="modelValue" class="badge" :style="{ backgroundColor: getCategoryColor(modelValue) }">
{{ modelValue }}
</span>
<span v-else class="placeholder">-- 선택 --</span>
</div>
<div v-if="isOpen" class="category-dropdown">
<div class="category-list">
<div v-for="cat in categories" :key="cat.name" class="category-item">
<div class="badge-area" @click="selectCategory(cat.name)">
<span class="badge" :style="{ backgroundColor: cat.color }">{{ cat.name }}</span>
</div>
<button class="btn-cat-del" @click.stop="removeCategory(cat.name)">&times;</button>
</div>
<div v-if="categories.length === 0" class="empty-cat">검색된 항목이 없습니다.</div>
</div>
<div class="category-footer">
<div class="add-input-group">
<input v-model="newCategoryName" placeholder="항목 추가..." @keyup.enter="addNewCategory">
<button class="add-btn" @click="addNewCategory"><Plus :size="14"/></button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.category-select-container {
position: relative;
width: 100%;
}
.selected-box {
width: 100%;
padding: 6px;
border: none;
min-height: 38px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: transparent;
}
.badge {
color: white;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
}
.placeholder {
color: #94a3b8;
font-size: 0.85rem;
}
.category-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
width: 200px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
z-index: 1000;
margin-top: 8px;
padding: 10px;
}
.category-list {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
}
.category-item {
padding: 4px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
display: flex;
align-items: center;
justify-content: space-between;
}
.badge-area {
flex: 1;
display: flex;
}
.btn-cat-del {
background: transparent;
border: none;
color: #94a3b8;
font-size: 1.2rem;
line-height:1;
padding: 0 5px;
cursor: pointer;
}
.btn-cat-del:hover {
color: #ef4444;
}
.category-item:hover {
background: #f8fafc;
}
.empty-cat {
padding: 20px 0;
text-align: center;
color: #94a3b8;
font-size: 0.8rem;
}
.category-footer {
padding-top: 10px;
}
.add-input-group {
display: flex;
gap: 4px;
align-items: center;
width: 100%;
}
.add-input-group input {
flex: 1;
min-width: 0;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 0.8rem;
outline: none;
}
.add-btn {
flex-shrink: 0;
background: #3b82f6;
color: white;
border: none;
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
</style>
@@ -0,0 +1,588 @@
<script setup>
import { ref, computed } from 'vue'
import { Plus } from 'lucide-vue-next'
import CategorySelect from './CategorySelect.vue'
const autoYearEnabled = ref(true)
const props = defineProps({
formData: Object,
depositManagement: Object,
selectedBlogRound: String,
filteredBlogStatus: Array,
addRow: Function,
removeRow: Function,
formatNumber: Function,
completeCurrentRound: Function,
addNextRound: Function,
deleteRound: Function
})
const emit = defineEmits(['update:selectedBlogRound', 'save'])
const selectRound = (round) => {
emit('update:selectedBlogRound', round)
}
const maxRound = computed(() => {
if (!props.formData.blogRounds || props.formData.blogRounds.length === 0) return 1
return Math.max(...props.formData.blogRounds.map(r => r.number))
})
const handleAddCategory = (name) => {
if (!props.formData.serviceCategories.find(c => c.name === name)) {
const randomColor = '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6, '0')
props.formData.serviceCategories.push({ name, color: randomColor })
emit('save')
}
}
const handleRemoveCategory = (name) => {
props.formData.serviceCategories = props.formData.serviceCategories.filter(c => c.name !== name)
emit('save')
}
const getRelativeNo = (item) => {
// formData.blogStatus에서 해당 round인 항목만 필터링한 후 그 중의 index를 찾음
const itemsInSameRound = props.formData.blogStatus.filter(i => String(i.round) === String(item.round))
const idx = itemsInSameRound.indexOf(item)
return idx + 1
}
const handleTableKeydown = (e) => {
const key = e.key
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return
const input = e.target
if (['INPUT', 'TEXTAREA'].includes(input.tagName)) {
if (input.type === 'text' || input.type === 'number' || input.type === '' || input.tagName === 'TEXTAREA') {
if (key === 'ArrowLeft' && input.selectionStart > 0) return
if (key === 'ArrowRight' && input.selectionEnd < (input.value || '').length) return
// Enter의 경우 Shift+Enter가 아니면 다음 행으로 이동하도록 처리
if (key === 'Enter' && e.shiftKey) return
}
}
const td = input.closest('td')
if (!td) return
const tr = td.closest('tr')
const tbody = tr.closest('tbody')
if (!tbody) return
let colIdx = Array.from(tr.children).indexOf(td)
let rowIdx = Array.from(tbody.children).indexOf(tr)
let targetInput = null
while (!targetInput) {
if (key === 'ArrowUp') rowIdx--
else if (key === 'ArrowDown' || key === 'Enter') rowIdx++
else if (key === 'ArrowLeft') colIdx--
else if (key === 'ArrowRight') colIdx++
const targetTr = tbody.children[rowIdx]
if (!targetTr) return
const targetTd = targetTr.children[colIdx]
if (!targetTd) return
targetInput = targetTd.querySelector('input:not([type="hidden"]), select, textarea')
}
if (targetInput) {
e.preventDefault()
targetInput.focus()
if (targetInput.tagName === 'INPUT' || targetInput.tagName === 'TEXTAREA') {
targetInput.select()
}
}
}
const handleDepositDateBlur = (item) => {
if (!item.date) return
let val = item.date.replace(/[^0-9]/g, '')
if (autoYearEnabled.value && (val.length === 3 || val.length === 4)) {
const currentYear = new Date().getFullYear()
let m, d
if (val.length === 3) {
m = '0' + val.substring(0, 1)
d = val.substring(1, 3)
} else {
m = val.substring(0, 2)
d = val.substring(2, 4)
}
item.date = `${currentYear}.${m}.${d}`
return
}
if (val.length === 6) {
item.date = `20${val.substring(0, 2)}.${val.substring(2, 4)}.${val.substring(4, 6)}`
} else if (val.length === 8) {
item.date = `${val.substring(0, 4)}.${val.substring(4, 6)}.${val.substring(6, 8)}`
}
}
</script>
<template>
<div class="hub-scroll-view">
<!-- 입금액 상세 (보고서용) -->
<div class="form-card">
<div class="header-with-btn">
<h3>입금액 상세 (보고서 노출용)</h3>
<div style="display: flex; gap: 10px; align-items: center;">
<button class="btn-toggle-year" :class="{ active: autoYearEnabled }" @click="autoYearEnabled = !autoYearEnabled">
현재년도 자동완성 {{ autoYearEnabled ? 'ON' : 'OFF' }}
</button>
<button class="btn-add-mini"
@click="addRow(formData.depositDetails.table, { round: '', date: '', service: '네이버 블로그', amount: '', note: '' })">
<Plus :size="14" /> 추가
</button>
</div>
</div>
<table class="hub-table" @keydown="handleTableKeydown">
<thead>
<tr>
<th width="80">회차</th>
<th width="120">일자</th>
<th>항목/서비스</th>
<th width="150">입금금액</th>
<th>비고</th>
<th width="50"></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in formData.depositDetails.table" :key="idx">
<td>
<select v-model="item.round" class="table-select">
<option value="">--</option>
<option v-for="n in maxRound + 1" :key="n" :value="n">
{{ n }}회차
</option>
</select>
</td>
<td><input v-model="item.date" class="table-input" placeholder="MM-DD" @blur="handleDepositDateBlur(item)"></td>
<td>
<CategorySelect
v-model="item.service"
:categories="formData.serviceCategories"
@add-category="handleAddCategory"
@remove-category="handleRemoveCategory"
/>
</td>
<td><input :value="formatNumber(item.amount)"
@input="item.amount = $event.target.value.replace(/[^0-9]/g, '')" class="table-input"></td>
<td><input v-model="item.note" class="table-input"></td>
<td><button class="btn-row-del"
@click="removeRow(formData.depositDetails.table, idx)">&times;</button></td>
</tr>
</tbody>
</table>
</div>
<!-- 결제 요약 (관리 전용) -->
<div class="form-card">
<div class="header-with-btn">
<h3>결제 요약 현황</h3>
<button class="btn-add-mini"
@click="addRow(depositManagement.summary, { service: '네이버 블로그', totalAmount: '', rounds: '', completedAmount: '' })">
<Plus :size="14" /> 추가
</button>
</div>
<table class="hub-table" @keydown="handleTableKeydown">
<thead>
<tr>
<th>항목/서비스</th>
<th>완료회차</th>
<th>완료 금액</th>
<th>총결제 금액</th>
<th width="50"></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in depositManagement.summary" :key="idx">
<td>
<CategorySelect
v-model="item.service"
:categories="formData.serviceCategories"
@add-category="handleAddCategory"
@remove-category="handleRemoveCategory"
/>
</td>
<td><input v-model="item.rounds" class="table-input"></td>
<td><input :value="formatNumber(item.completedAmount)"
@input="item.completedAmount = $event.target.value.replace(/[^0-9]/g, '')" class="table-input">
</td>
<td><input :value="formatNumber(item.totalAmount)"
@input="item.totalAmount = $event.target.value.replace(/[^0-9]/g, '')" class="table-input"></td>
<td><button class="btn-row-del" @click="removeRow(depositManagement.summary, idx)">&times;</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 진행회차 상세 (관리 전용) -->
<div class="form-card">
<div class="header-with-btn">
<h3>진행회차 상세</h3>
<button class="btn-add-mini"
@click="addRow(depositManagement.details, { round: '', status: '', method: '', status2: '', subAmount: '' })">
<Plus :size="14" /> 추가
</button>
</div>
<table class="hub-table" @keydown="handleTableKeydown">
<thead>
<tr>
<th>회차</th>
<th>상태</th>
<th>결제 방식</th>
<th>비고</th>
<th>금액</th>
<th width="50"></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in depositManagement.details" :key="idx">
<td>
<select v-model="item.round" class="table-select">
<option value="">--</option>
<option v-for="n in maxRound + 1" :key="n" :value="n">
{{ n }}회차
</option>
</select>
</td>
<td><input v-model="item.status" class="table-input"></td>
<td><input v-model="item.method" class="table-input"></td>
<td><input v-model="item.status2" class="table-input"></td>
<td><input :value="formatNumber(item.subAmount)"
@input="item.subAmount = $event.target.value.replace(/[^0-9]/g, '')" class="table-input"></td>
<td><button class="btn-row-del" @click="removeRow(depositManagement.details, idx)">&times;</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 발행 현황 관리 (회차별 리스트 관리) -->
<div class="form-card">
<div class="round-control-top">
<div class="round-selector">
<button
:class="['round-btn', { active: selectedBlogRound === '전체' }]"
@click="selectRound('전체')">전체</button>
<button
v-for="r in formData.blogRounds"
:key="r.number"
:class="['round-btn', r.completed ? 'round-completed' : 'round-active', { active: selectedBlogRound === String(r.number) }]"
@click="selectRound(String(r.number))">
{{ r.number }}회차
</button>
</div>
<button v-if="selectedBlogRound !== '전체'" class="btn-delete-round" @click="deleteRound(parseInt(selectedBlogRound))">
회차 삭제
</button>
</div>
<div class="header-with-btn border-top">
<h3>발행 현황 관리</h3>
<div class="header-right-btns">
<button class="btn-toggle-year" :class="{ active: autoYearEnabled }" @click="autoYearEnabled = !autoYearEnabled" style="margin-right: 10px;">
현재년도 자동완성 {{ autoYearEnabled ? 'ON' : 'OFF' }}
</button>
<button v-if="selectedBlogRound !== '전체' && !formData.blogRounds.find(r => r.number === parseInt(selectedBlogRound))?.completed"
class="btn-complete-round" @click="completeCurrentRound">
완료
</button>
<button v-if="selectedBlogRound !== '전체' && formData.blogRounds.find(r => r.number === parseInt(selectedBlogRound))?.completed && parseInt(selectedBlogRound) === formData.blogRounds.length"
class="btn-next-round" @click="addNextRound">
다음 회차 추가
</button>
<button class="btn-add-mini"
@click="addRow(formData.blogStatus, {
date: '', type: '', title: '', link: '',
round: (selectedBlogRound === '전체' ? formData.blogRounds[formData.blogRounds.length-1].number : parseInt(selectedBlogRound))
})">
<Plus :size="14" /> 추가
</button>
</div>
</div>
<table class="hub-table" @keydown="handleTableKeydown">
<thead>
<tr>
<th width="40">No.</th>
<th width="120">발행 예정일</th>
<th width="120">발행 유형</th>
<th>제목</th>
<th>URL(링크)</th>
<th width="80">회차</th>
<th width="50"></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in filteredBlogStatus" :key="idx">
<td class="no-col">{{ getRelativeNo(item) }}</td>
<td><input v-model="item.date" class="table-input" placeholder="예: 0223" @blur="handleDepositDateBlur(item)"></td>
<td><input v-model="item.type" class="table-input" placeholder="유형"></td>
<td><textarea v-model="item.title" class="table-input table-textarea" placeholder="제목"></textarea></td>
<td><textarea v-model="item.link" class="table-input table-textarea" placeholder="https://..."></textarea></td>
<td><span class="round-badge">{{ item.round }}회차</span></td>
<td><button class="btn-row-del" @click="removeRow(formData.blogStatus, formData.blogStatus.indexOf(item))">&times;</button></td>
</tr>
<tr v-if="filteredBlogStatus.length === 0">
<td colspan="6" class="empty-msg">선택된 회차의 발행 내역이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.form-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.form-card h3 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1e293b;
}
.hub-table {
width: 100%;
border-collapse: collapse;
}
.hub-table th {
text-align: center;
padding: 12px;
background: #f8fafc;
font-size: 0.8rem;
color: #64748b;
border-bottom: 2px solid #f1f5f9;
}
.hub-table td {
padding: 8px;
border-bottom: 1px solid #f1f5f9;
text-align: center;
}
.table-input {
width: 100%;
border: none;
background: transparent;
padding: 8px;
outline: none;
text-align: center;
}
.table-input:focus {
background: #f8fafc;
}
.table-textarea {
resize: none;
min-height: 46px;
display: flex;
align-items: center;
justify-content: center;
vertical-align: middle;
padding-top: 12px;
}
.table-select {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 4px;
background: white;
outline: none;
font-size: 0.85rem;
cursor: pointer;
text-align: center;
}
.table-select:focus {
border-color: var(--primary);
}
.btn-add-mini {
background: var(--primary);
color: white;
border: none;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
}
.btn-row-del {
color: #ef4444;
border: none;
background: none;
font-size: 1.25rem;
cursor: pointer;
}
.header-with-btn {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header-with-btn.border-top {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px dashed #e2e8f0;
}
.hub-scroll-view {
display: flex;
flex-direction: column;
gap: 1rem;
}
.btn-toggle-year {
padding: 6px 12px;
border: 1px solid #cbd5e1;
background: #f8fafc;
color: #64748b;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-toggle-year.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* 회차 관리 스타일 */
.round-control-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.round-selector {
display: flex;
gap: 6px;
background: #f1f5f9;
padding: 4px;
border-radius: 8px;
}
.round-btn {
padding: 7px 16px;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
border: none;
background: transparent;
color: #94a3b8;
transition: all 0.2s;
}
.round-btn.active {
background: white;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
}
.round-active {
color: white !important;
background: #10b981 !important;
}
.round-completed {
color: white !important;
background: #3b82f6 !important;
}
.round-btn:not(.active) {
opacity: 0.6;
}
.round-btn:not(.round-active):not(.round-completed):not(.active) {
color: #64748b;
background: transparent;
}
.btn-delete-round {
background: #fef2f2;
color: #ef4444;
border: 1px solid #fecaca;
padding: 6px 14px;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.header-right-btns {
display: flex;
gap: 10px;
align-items: center;
}
.btn-complete-round {
background: white;
color: #3b82f6;
border: 1px solid #3b82f6;
padding: 7px 16px;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.btn-next-round {
background: #3b82f6;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.empty-msg {
padding: 2rem;
color: #94a3b8;
}
.no-col {
font-size: 0.8rem;
font-weight: 500;
color: #64748b;
}
.round-badge {
display: inline-block;
padding: 4px 8px;
background: #f1f5f9;
color: #64748b;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
}
</style>
@@ -0,0 +1,518 @@
<script setup>
import { ref, computed } from 'vue'
import { Plus } from 'lucide-vue-next'
import apiClient from '../../api/client'
const autoYearEnabled = ref(true)
const formMonthFilter = ref('')
const props = defineProps({
formData: Object,
addRow: Function,
removeRow: Function,
marketingFile: null
})
const currentBlogRound = computed(() => {
const table = props.formData.depositDetails?.table || []
let maxRound = 0
let found = false
for (const row of table) {
if ((row.service || '').includes('블로그')) {
const rNum = parseInt(String(row.round || '').replace(/[^0-9]/g, ''))
if (!isNaN(rNum) && rNum > maxRound) {
maxRound = rNum
found = true
}
}
}
return found ? maxRound : null
})
const uniqueFormMonths = computed(() => {
const table = props.formData.inflowData.table || []
return [...new Set(table.map(d => d.month ? d.month.substring(0, 7) : '').filter(Boolean))].sort().reverse()
})
const getFullUrl = (url) => {
if (!url) return ''
if (url.startsWith('data:') || url.startsWith('http')) return url
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
return `${baseUrl}${url}`
}
const triggerFileUpload = (idx) => {
const el = document.getElementById(`file-hub-${idx}`)
if (el) el.click()
}
const handleFileUpload = async (idx, event) => {
const file = event.target.files[0]
if (file) {
const formDataUpload = new FormData()
formDataUpload.append('image', file)
try {
const res = await apiClient.post('/api/upload', formDataUpload)
const data = await res.json()
if (data.imageUrl) {
props.formData.exposureStatus[idx].image = data.imageUrl
}
} catch (err) {
console.error('Image upload failed:', err)
alert('이미지 업로드에 실패했습니다.')
}
}
}
const handleDateBlur = (item) => {
if (!item.month) return
let val = item.month.replace(/[^0-9]/g, '')
if (autoYearEnabled.value && (val.length === 3 || val.length === 4)) {
const currentYear = new Date().getFullYear()
let m, d
if (val.length === 3) {
m = '0' + val.substring(0, 1)
d = val.substring(1, 3)
} else {
m = val.substring(0, 2)
d = val.substring(2, 4)
}
item.month = `${currentYear}-${m}-${d}`
calculateDiff(item)
return
}
if (val.length === 6) {
item.month = `20${val.substring(0, 2)}-${val.substring(2, 4)}-${val.substring(4, 6)}`
} else if (val.length === 8) {
item.month = `${val.substring(0, 4)}-${val.substring(4, 6)}-${val.substring(6, 8)}`
}
calculateDiff(item)
}
const handleValueBlur = (item) => {
if (item.value && String(item.value).trim() !== '') {
// 공백, 콤마 등으로 구분된 숫자들을 모두 더함
const numbers = String(item.value).split(/[\s,]+/).map(n => parseFloat(n)).filter(n => !isNaN(n))
if (numbers.length > 0) {
const sum = numbers.reduce((a, b) => a + b, 0)
item.sum = sum.toFixed(2)
} else {
item.sum = '0.00'
}
} else {
item.sum = ''
}
calculateDiff(item)
}
const calculateDiff = (item) => {
// 에디터에서는 더 이상 대비를 직접 계산하거나 저장하지 않습니다.
}
const handleTableKeydown = (e) => {
const key = e.key
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return
const input = e.target
if (['INPUT', 'TEXTAREA'].includes(input.tagName)) {
if (input.type === 'text' || input.type === 'number' || input.type === '' || input.tagName === 'TEXTAREA') {
if (key === 'ArrowLeft' && input.selectionStart > 0) return
if (key === 'ArrowRight' && input.selectionEnd < (input.value || '').length) return
// Enter의 경우 Shift+Enter가 아니면 다음 행으로 이동하도록 처리
if (key === 'Enter' && e.shiftKey) return
}
}
const td = input.closest('td')
if (!td) return
const tr = td.closest('tr')
const tbody = tr.closest('tbody')
if (!tbody) return
let colIdx = Array.from(tr.children).indexOf(td)
let rowIdx = Array.from(tbody.children).indexOf(tr)
let targetInput = null
while (!targetInput) {
if (key === 'ArrowUp') rowIdx--
else if (key === 'ArrowDown' || key === 'Enter') rowIdx++
else if (key === 'ArrowLeft') colIdx--
else if (key === 'ArrowRight') colIdx++
const targetTr = tbody.children[rowIdx]
if (!targetTr) return
const targetTd = targetTr.children[colIdx]
if (!targetTd) return
targetInput = targetTd.querySelector('input:not([type="hidden"]), select, textarea')
}
if (targetInput) {
e.preventDefault()
targetInput.focus()
if (targetInput.tagName === 'INPUT' || targetInput.tagName === 'TEXTAREA') {
targetInput.select()
}
}
}
</script>
<template>
<div class="hub-scroll-view">
<div class="form-card">
<div class="header-with-btn">
<h3>유입 경로 분석</h3>
<div style="display: flex; gap: 10px; align-items: center;">
<select v-model="formMonthFilter" class="form-month-select">
<option value="">전체 (날짜순)</option>
<option v-for="m in uniqueFormMonths" :key="m" :value="m">{{ m }}</option>
</select>
<button class="btn-toggle-year" :class="{ active: autoYearEnabled }"
@click="autoYearEnabled = !autoYearEnabled">
현재년도 자동완성 {{ autoYearEnabled ? 'ON' : 'OFF' }}
</button>
</div>
</div>
<table class="hub-table" @keydown="handleTableKeydown">
<thead>
<tr>
<th width="140">측정원/기준일</th>
<th>경로명</th>
<th>비중(%) <small>(: 50.5 50.5)</small></th>
<th width="100">합계</th>
<th width="50"></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in formData.inflowData.table" :key="idx" class="inflow-hub-row"
v-show="!formMonthFilter || String(item.month || '').startsWith(formMonthFilter)">
<td><input type="text" v-model="item.month" @blur="handleDateBlur(item)" class="table-input text-center"
placeholder="YYYY-MM-DD"></td>
<td><textarea v-model="item.label" class="table-input table-textarea"></textarea></td>
<td><input v-model="item.value" class="table-input" @blur="handleValueBlur(item)" placeholder="숫자 입력"></td>
<td><div class="sum-cell">{{ item.sum || '-' }}</div></td>
<td><button class="btn-row-del" @click="removeRow(formData.inflowData.table, idx)">&times;</button>
</td>
</tr>
</tbody>
</table>
<button class="btn-add-inline"
@click="addRow(formData.inflowData.table, { month: '', label: '', value: '', sum: '', isUp: true })">+ 경로
추가</button>
<div class="mt-4">
<label class="block-label">유입 분석 상세 피드백</label>
<textarea v-model="formData.inflowData.description" class="hub-textarea mini"
placeholder="유입 경로에 대한 분석 내용을 입력하십시오."></textarea>
</div>
</div>
<div class="form-card">
<h3>전월 대비 성과 지표 비교</h3>
<div class="form-grid">
<div class="form-group" v-for="(val, key) in formData.comparisonData" :key="key">
<label>{{ key }} 비교</label>
<input v-model="formData.comparisonData[key]" placeholder="예: 지난달 대비 15% 상승" class="hub-input">
</div>
</div>
</div>
<div class="form-card">
<h3>{{ currentBlogRound ? `(${currentBlogRound}회차)` : '' }} 진단결과 (성과 비교 분석 하단 표기)</h3>
<textarea v-model="formData.diagnosisResult" class="hub-textarea" placeholder="진단결과를 입력하세요."></textarea>
</div>
<div class="form-card">
<h3>리포트 종합 요약 (강조 문구)</h3>
<textarea v-model="formData.reportSummary" class="hub-textarea"
placeholder="리포트 하단에 강조될 핵심 총평을 입력하세요 (예: 3월 통합 모니터링 결과 지표 대폭 상승)"></textarea>
</div>
<div class="form-card">
<div class="header-with-btn">
<h3>상위 노출 현황 (키워드 이미지)</h3>
<button class="btn-add-mini" @click="addRow(formData.exposureStatus, { keyword: '', image: '' })">
<Plus :size="14" /> 추가
</button>
</div>
<div class="exposure-grid-hub">
<div v-for="(item, idx) in formData.exposureStatus" :key="idx" class="exposure-item-hub">
<input v-model="item.keyword" placeholder="키워드" class="kw-input">
<div class="img-hub-box" @click="triggerFileUpload(idx)">
<img v-if="item.image" :src="getFullUrl(item.image)" class="preview-img-hub">
<div v-else class="img-placeholder-hub">이미지 클릭 첨부</div>
<input :id="`file-hub-${idx}`" type="file" @change="e => handleFileUpload(idx, e)" hidden accept="image/*">
</div>
<button class="btn-row-del" @click="removeRow(formData.exposureStatus, idx)">삭제</button>
</div>
</div>
</div>
<div class="form-card">
<h3>차월 마케팅 계획</h3>
<textarea v-model="formData.nextMonthPlan" class="hub-textarea" placeholder="다음 달 계획을 상세히 입력하세요"></textarea>
</div>
</div>
</template>
<style scoped>
.form-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.btn-toggle-year {
padding: 6px 12px;
border: 1px solid #cbd5e1;
background: #f8fafc;
color: #64748b;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-toggle-year.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.form-month-select {
padding: 6px 10px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background-color: white;
font-size: 0.85rem;
color: #334155;
cursor: pointer;
}
.text-center {
text-align: center;
}
.header-with-btn {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.header-with-btn h3 {
margin-bottom: 0 !important;
}
.form-card h3 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1e293b;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: #64748b;
font-weight: 600;
}
.hub-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
outline: none;
}
.hub-input:focus {
border-color: var(--primary);
}
.hub-table {
width: 100%;
border-collapse: collapse;
}
.hub-table th {
text-align: center;
padding: 12px;
background: #f8fafc;
font-size: 0.8rem;
color: #64748b;
border-bottom: 2px solid #f1f5f9;
}
.hub-table td {
padding: 8px;
border-bottom: 1px solid #f1f5f9;
text-align: center;
}
.sum-cell {
font-weight: 700;
color: var(--primary);
font-size: 0.9rem;
}
.table-input {
width: 100%;
border: none;
background: transparent;
padding: 8px;
outline: none;
text-align: center;
}
.table-input:focus {
background: #f8fafc;
}
.table-textarea {
resize: none;
min-height: 46px;
display: flex;
align-items: center;
justify-content: center;
vertical-align: middle;
padding-top: 12px;
}
.btn-add-mini {
background: var(--primary);
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
}
.btn-row-del {
color: #ef4444;
border: none;
background: none;
font-size: 1.25rem;
cursor: pointer;
}
.header-with-btn {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.hub-textarea {
width: 100%;
height: 200px;
padding: 1rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
resize: none;
outline: none;
}
.hub-scroll-view {
display: flex;
flex-direction: column;
gap: 1rem;
}
.btn-add-inline {
width: 100%;
padding: 10px;
margin-top: 10px;
background: none;
border: 2px dashed #e2e8f0;
color: #94a3b8;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
.exposure-grid-hub {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.exposure-item-hub {
background: #f8fafc;
padding: 15px;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.kw-input {
width: 100%;
padding: 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
outline: none;
}
.img-hub-box {
width: 100%;
aspect-ratio: 16/9;
background: white;
border: 1px dashed #cbd5e1;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
}
.preview-img-hub {
width: 100%;
height: 100%;
object-fit: cover;
}
.img-placeholder-hub {
font-size: 0.75rem;
color: #94a3b8;
text-align: center;
}
.block-label {
display: block;
margin-top: 1rem;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: #64748b;
font-weight: 600;
}
.hub-textarea.mini {
height: 100px;
font-size: 0.9rem;
}
.mt-4 {
margin-top: 1rem;
}
</style>
@@ -0,0 +1,88 @@
<script setup>
const props = defineProps({
formData: Object
})
</script>
<template>
<div class="form-card">
<h3>이체 결제 정보 (우리 회사)</h3>
<div class="form-grid">
<div class="form-group">
<label>회사명</label>
<input v-model="formData.transferInfo.companyName">
</div>
<div class="form-group">
<label>대표자</label>
<input v-model="formData.transferInfo.ceo">
</div>
<div class="form-group">
<label>사업자 번호</label>
<input v-model="formData.transferInfo.businessNumber">
</div>
<div class="form-group">
<label>담당자</label>
<input v-model="formData.transferInfo.ourManager" placeholder="예: 박민준, 유경미">
</div>
<div class="form-group">
<label>은행명</label>
<input v-model="formData.transferInfo.bankName">
</div>
<div class="form-group">
<label>예금주</label>
<input v-model="formData.transferInfo.holder">
</div>
<div class="form-group">
<label>계좌번호</label>
<input v-model="formData.transferInfo.account">
</div>
<div class="form-group">
<label>입금 금액</label>
<input v-model="formData.transferInfo.amount" placeholder="예: 3,000,000원">
</div>
</div>
</div>
</template>
<style scoped>
.form-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.form-card h3 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1e293b;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: #64748b;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
outline: none;
}
.form-group input:focus {
border-color: var(--primary);
}
</style>
@@ -0,0 +1,322 @@
<script setup>
import { ref } from 'vue'
import { Plus } from 'lucide-vue-next'
const autoYearEnabled = ref(true)
const props = defineProps({
formData: Object,
removeRow: Function
})
const addVisitorRow = () => {
if (!props.formData.visitorData.table) {
props.formData.visitorData.table = []
}
props.formData.visitorData.table.push({
selected: true,
date: '',
value: '',
diff: '-',
isUp: true,
viewValue: '',
viewDiff: '-',
viewIsUp: true
})
}
const calculateAllStats = () => {
if (!props.formData.visitorData.table) return
props.formData.visitorData.table.forEach((current, idx) => {
const prev = props.formData.visitorData.table[idx - 1]
if (!current.value || !prev?.value) {
current.diff = '-'
current.isUp = true
} else {
const curVal = parseInt(String(current.value).replace(/,/g, ''))
const prevVal = parseInt(String(prev.value).replace(/,/g, ''))
if (!isNaN(curVal) && !isNaN(prevVal) && prevVal !== 0) {
const diffPercent = ((curVal - prevVal) / prevVal * 100).toFixed(1)
current.isUp = curVal >= prevVal
current.diff = (current.isUp ? '▲ ' : '▼ ') + Math.abs(diffPercent) + '%'
} else {
current.diff = '-'
}
}
if (!current.viewValue || !prev?.viewValue) {
current.viewDiff = '-'
current.viewIsUp = true
} else {
const curVal = parseInt(String(current.viewValue).replace(/,/g, ''))
const prevVal = parseInt(String(prev.viewValue).replace(/,/g, ''))
if (!isNaN(curVal) && !isNaN(prevVal) && prevVal !== 0) {
const diffPercent = ((curVal - prevVal) / prevVal * 100).toFixed(1)
current.viewIsUp = curVal >= prevVal
current.viewDiff = (current.viewIsUp ? '▲ ' : '▼ ') + Math.abs(diffPercent) + '%'
} else {
current.viewDiff = '-'
}
}
})
}
const sortAndRecalculate = () => {
props.formData.visitorData.table.sort((a, b) => {
if (!a.date) return 1
if (!b.date) return -1
return a.date.localeCompare(b.date)
})
calculateAllStats()
}
const handleDateBlur = (item) => {
if (!item.date) return
let val = item.date.replace(/[^0-9]/g, '')
if (autoYearEnabled.value && (val.length === 3 || val.length === 4)) {
const currentYear = new Date().getFullYear()
let m, d
if (val.length === 3) {
m = '0' + val.substring(0, 1)
d = val.substring(1, 3)
} else {
m = val.substring(0, 2)
d = val.substring(2, 4)
}
item.date = `${currentYear}-${m}-${d}`
sortAndRecalculate()
return
}
if (val.length === 6) {
item.date = `20${val.substring(0, 2)}-${val.substring(2, 4)}-${val.substring(4, 6)}`
} else if (val.length === 8) {
item.date = `${val.substring(0, 4)}-${val.substring(4, 6)}-${val.substring(6, 8)}`
}
sortAndRecalculate()
}
const handleTableKeydown = (e) => {
const key = e.key
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return
const input = e.target
if (['INPUT', 'TEXTAREA'].includes(input.tagName)) {
if (input.type === 'text' || input.type === 'number' || input.type === '' || input.tagName === 'TEXTAREA') {
if (key === 'ArrowLeft' && input.selectionStart > 0) return
if (key === 'ArrowRight' && input.selectionEnd < (input.value || '').length) return
// Enter의 경우 Shift+Enter가 아니면 다음 행으로 이동하도록 처리
if (key === 'Enter' && e.shiftKey) return
}
}
const td = input.closest('td')
if (!td) return
const tr = td.closest('tr')
const tbody = tr.closest('tbody')
if (!tbody) return
let colIdx = Array.from(tr.children).indexOf(td)
let rowIdx = Array.from(tbody.children).indexOf(tr)
let targetInput = null
while (!targetInput) {
if (key === 'ArrowUp') rowIdx--
else if (key === 'ArrowDown' || key === 'Enter') rowIdx++
else if (key === 'ArrowLeft') colIdx--
else if (key === 'ArrowRight') colIdx++
const targetTr = tbody.children[rowIdx]
if (!targetTr) return
const targetTd = targetTr.children[colIdx]
if (!targetTd) return
targetInput = targetTd.querySelector('input:not([type="hidden"]), select, textarea')
}
if (targetInput) {
e.preventDefault()
targetInput.focus()
if (targetInput.tagName === 'INPUT' || targetInput.tagName === 'TEXTAREA') {
targetInput.select()
}
}
}
// 부모에서 호출할 수 있도록 노출
defineExpose({ calculateAllStats })
</script>
<template>
<div class="form-card">
<div class="header-with-btn">
<h3>방문자 조회수 추이</h3>
<div style="display: flex; gap: 10px; align-items: center;">
<button class="btn-toggle-year" :class="{ active: autoYearEnabled }" @click="autoYearEnabled = !autoYearEnabled">
현재년도 자동완성 {{ autoYearEnabled ? 'ON' : 'OFF' }}
</button>
<button class="btn-add-mini" @click="addVisitorRow">
<Plus :size="14" /> 추가
</button>
</div>
</div>
<div class="info-msg">날짜와 데이터를 입력하면 전일 대비 증감율이 자동 계산됩니다.</div>
<table class="hub-table" @keydown="handleTableKeydown">
<thead>
<tr>
<th width="40"></th>
<th width="140">날짜</th>
<th>방문자 </th>
<th width="100">전일대비</th>
<th>조회수</th>
<th width="100">전일대비</th>
<th width="50"></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in formData.visitorData.table" :key="idx">
<td><input type="checkbox" v-model="item.selected"></td>
<td><input v-model="item.date" class="table-input" placeholder="2026-03-27"
@blur="handleDateBlur(item)"></td>
<td><input v-model="item.value" class="table-input" @input="calculateAllStats" placeholder="방문자"></td>
<td :class="item.isUp ? 'text-up' : 'text-down'">{{ item.diff }}</td>
<td><input v-model="item.viewValue" class="table-input" @input="calculateAllStats" placeholder="조회수">
</td>
<td :class="item.viewIsUp ? 'text-up' : 'text-down'">{{ item.viewDiff }}</td>
<td><button class="btn-row-del" @click="removeRow(formData.visitorData.table, idx)">&times;</button>
</td>
</tr>
<tr v-if="formData.visitorData.table.length === 0">
<td colspan="7" class="empty-msg">데이터를 추가해 주세요.</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.form-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.btn-toggle-year {
padding: 6px 12px;
border: 1px solid #cbd5e1;
background: #f8fafc;
color: #64748b;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-toggle-year.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.form-card h3 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1e293b;
}
.hub-table {
width: 100%;
border-collapse: collapse;
}
.hub-table th {
text-align: center;
padding: 12px;
background: #f8fafc;
font-size: 0.8rem;
color: #64748b;
border-bottom: 2px solid #f1f5f9;
}
.hub-table td {
padding: 8px;
border-bottom: 1px solid #f1f5f9;
text-align: center;
}
.table-input {
width: 100%;
border: none;
background: transparent;
padding: 8px;
outline: none;
text-align: center;
}
.table-input:focus {
background: #f8fafc;
}
.btn-add-mini {
background: var(--primary);
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
}
.btn-row-del {
color: #ef4444;
border: none;
background: none;
font-size: 1.25rem;
cursor: pointer;
}
.header-with-btn {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.info-msg {
font-size: 0.8rem;
color: var(--primary);
margin-bottom: 1rem;
background: aliceblue;
padding: 8px 12px;
border-radius: 6px;
}
.text-up {
color: #ef4444;
font-weight: 700;
}
.text-down {
color: #2563eb;
font-weight: 700;
}
.empty-msg {
padding: 2rem;
color: #94a3b8;
font-size: 0.9rem;
}
</style>
@@ -0,0 +1,91 @@
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
name: String,
companyInfo: Object
})
const emit = defineEmits(['update:name', 'update:companyInfo'])
const updateName = (val) => emit('update:name', val)
const updateInfo = (key, val) => {
const newInfo = { ...props.companyInfo, [key]: val }
emit('update:companyInfo', newInfo)
}
</script>
<template>
<div class="form-grid">
<div class="form-group">
<label>업체명 (리포트용)</label>
<input
:value="name"
@input="updateName($event.target.value)"
placeholder="업체명">
</div>
<div class="form-group">
<label>담당자</label>
<input
:value="companyInfo.manager"
@input="updateInfo('manager', $event.target.value)"
placeholder="담당자 성함">
</div>
<div class="form-group">
<label>연락처</label>
<input
:value="companyInfo.contact"
@input="updateInfo('contact', $event.target.value)"
placeholder="010-0000-0000">
</div>
<div class="form-group">
<label>사업자 번호</label>
<input
:value="companyInfo.businessNumber"
@input="updateInfo('businessNumber', $event.target.value)"
placeholder="000-00-00000">
</div>
<div class="form-group">
<label>네이버 ID/PW</label>
<input
:value="companyInfo.naverIdPw"
@input="updateInfo('naverIdPw', $event.target.value)"
placeholder="id / password">
</div>
<div class="form-group full">
<label>블로그 링크</label>
<input
:value="companyInfo.blogLink"
@input="updateInfo('blogLink', $event.target.value)"
placeholder="https://blog.naver.com/...">
</div>
</div>
</template>
<style scoped>
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.form-group.full {
grid-column: span 2;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: #475569;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
outline: none;
}
.form-group input:focus {
border-color: #2563eb;
}
</style>
@@ -0,0 +1,116 @@
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
blogStatus: Array
})
const emit = defineEmits(['update:blogStatus'])
const addRow = () => {
const list = [...props.blogStatus]
list.push({ date: '', type: '', title: '', link: '' })
emit('update:blogStatus', list)
}
const removeRow = (idx) => {
const list = [...props.blogStatus]
list.splice(idx, 1)
emit('update:blogStatus', list)
}
const handleLinkUpdate = (item) => {
if (item.status) { // Only if status exists
if (item.link && item.link.trim() !== '' && item.link !== 'https://...') {
item.status = '발행완료'
} else {
item.status = '임시저장'
}
}
emit('update:blogStatus', [...props.blogStatus])
}
const handleTableKeydown = (e) => {
const key = e.key
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return
const input = e.target
if (['INPUT', 'TEXTAREA'].includes(input.tagName)) {
if (input.type === 'text' || input.type === 'number' || input.type === '' || input.tagName === 'TEXTAREA') {
if (key === 'ArrowLeft' && input.selectionStart > 0) return
if (key === 'ArrowRight' && input.selectionEnd < (input.value || '').length) return
if (key === 'Enter' && e.shiftKey) return
}
}
const td = input.closest('td')
if (!td) return
const tr = td.closest('tr')
const tbody = tr.closest('tbody')
if (!tbody) return
let colIdx = Array.from(tr.children).indexOf(td)
let rowIdx = Array.from(tbody.children).indexOf(tr)
let targetInput = null
while (!targetInput) {
if (key === 'ArrowUp') rowIdx--
else if (key === 'ArrowDown' || key === 'Enter') rowIdx++
else if (key === 'ArrowLeft') colIdx--
else if (key === 'ArrowRight') colIdx++
const targetTr = tbody.children[rowIdx]
if (!targetTr) return
const targetTd = targetTr.children[colIdx]
if (!targetTd) return
targetInput = targetTd.querySelector('input:not([type="hidden"]), select, textarea')
}
if (targetInput) {
e.preventDefault()
targetInput.focus()
if (targetInput.tagName === 'INPUT' || targetInput.tagName === 'TEXTAREA') {
targetInput.select()
}
}
}
</script>
<template>
<div class="blog-reporting">
<table class="edit-table" @keydown="handleTableKeydown">
<thead>
<tr>
<th width="120">발행 예정일</th>
<th width="120">발행 유형</th>
<th>제목</th>
<th>URL</th>
<th width="60"></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in blogStatus" :key="idx">
<td><input v-model="item.date" placeholder="03-27" @input="emit('update:blogStatus', [...blogStatus])"></td>
<td><input v-model="item.type" placeholder="유형" @input="emit('update:blogStatus', [...blogStatus])"></td>
<td><textarea v-model="item.title" placeholder="글 제목" @input="emit('update:blogStatus', [...blogStatus])" class="table-textarea"></textarea></td>
<td><textarea v-model="item.link" placeholder="URL" @input="handleLinkUpdate(item)" class="table-textarea"></textarea></td>
<td><button @click="removeRow(idx)" class="btn-del">삭제</button></td>
</tr>
</tbody>
</table>
<button class="btn-add-row" @click="addRow">+ 발행 내역 추가</button>
</div>
</template>
<style scoped>
.edit-table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; }
.edit-table th { text-align: left; padding: 10px; background: #f8fafc; font-size: 0.8rem; color: #64748b; }
.edit-table td { padding: 8px; border-bottom: 1px solid #f1f5f9; }
.edit-table input { width: 100%; padding: 6px; border: 1px solid #e2e8f0; border-radius: 4px; outline: none; }
.table-textarea { width: 100%; padding: 6px; border: 1px solid #e2e8f0; border-radius: 4px; outline: none; resize: none; min-height: 46px; font-family: inherit; font-size: inherit; }
.btn-del { color: #ef4444; background: none; border: none; font-size: 0.75rem; cursor: pointer; }
.btn-add-row { width: 100%; padding: 10px; border: 2px dashed #e2e8f0; border-radius: 8px; background: none; color: #94a3b8; cursor: pointer; font-weight: 600; margin-top: 10px; }
.btn-add-row:hover { border-color: #2563eb; color: #2563eb; }
</style>
@@ -0,0 +1,127 @@
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
depositDetails: Object
})
const emit = defineEmits(['update:depositDetails'])
const addRow = () => {
const table = props.depositDetails.table || []
table.push({ round: '', date: '', service: '', amount: '', note: '' })
emit('update:depositDetails', { ...props.depositDetails, table })
}
const removeRow = (idx) => {
const table = props.depositDetails.table || []
table.splice(idx, 1)
emit('update:depositDetails', { ...props.depositDetails, table })
}
const updateDates = (val) => {
emit('update:depositDetails', { ...props.depositDetails, dates: val })
}
const handleTableKeydown = (e) => {
const key = e.key
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return
const input = e.target
if (['INPUT', 'TEXTAREA'].includes(input.tagName)) {
if (input.type === 'text' || input.type === 'number' || input.type === '' || input.tagName === 'TEXTAREA') {
if (key === 'ArrowLeft' && input.selectionStart > 0) return
if (key === 'ArrowRight' && input.selectionEnd < (input.value || '').length) return
if (key === 'Enter' && e.shiftKey) return
}
}
const td = input.closest('td')
if (!td) return
const tr = td.closest('tr')
const tbody = tr.closest('tbody')
if (!tbody) return
let colIdx = Array.from(tr.children).indexOf(td)
let rowIdx = Array.from(tbody.children).indexOf(tr)
let targetInput = null
while (!targetInput) {
if (key === 'ArrowUp') rowIdx--
else if (key === 'ArrowDown' || key === 'Enter') rowIdx++
else if (key === 'ArrowLeft') colIdx--
else if (key === 'ArrowRight') colIdx++
const targetTr = tbody.children[rowIdx]
if (!targetTr) return
const targetTd = targetTr.children[colIdx]
if (!targetTd) return
targetInput = targetTd.querySelector('input:not([type="hidden"]), select, textarea')
}
if (targetInput) {
e.preventDefault()
targetInput.focus()
if (targetInput.tagName === 'INPUT' || targetInput.tagName === 'TEXTAREA') {
targetInput.select()
}
}
}
</script>
<template>
<div class="deposit-tracking">
<div class="info-alert">업체별 입금 일정과 회차별 상세 금액을 관리할 있습니다.</div>
<div class="deposit-config-group">
<div class="form-group full">
<label>입금일 안내 (: 매월 15, 27)</label>
<input
:value="depositDetails.dates"
@input="updateDates($event.target.value)"
placeholder="입금일을 콤마(,)로 구분하여 입력 (예: 15, 27)">
</div>
</div>
<table class="edit-table mt-4" @keydown="handleTableKeydown">
<thead>
<tr>
<th width="100">회차</th>
<th width="150">일자</th>
<th>항목/서비스</th>
<th width="150">금액</th>
<th>비고</th>
<th width="60"></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in depositDetails.table" :key="idx">
<td><input v-model="item.round" placeholder="1회차" @input="emit('update:depositDetails', { ...depositDetails })"></td>
<td><input v-model="item.date" placeholder="03-27" @input="emit('update:depositDetails', { ...depositDetails })"></td>
<td><input v-model="item.service" placeholder="블로그 마케팅" @input="emit('update:depositDetails', { ...depositDetails })"></td>
<td><input v-model="item.amount" placeholder="500,000" @input="emit('update:depositDetails', { ...depositDetails })"></td>
<td><input v-model="item.note" placeholder="완료" @input="emit('update:depositDetails', { ...depositDetails })"></td>
<td><button @click="removeRow(idx)" class="btn-del">삭제</button></td>
</tr>
</tbody>
</table>
<button class="btn-add-row" @click="addRow">+ 입금 내역 추가</button>
</div>
</template>
<style scoped>
.edit-table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; }
.edit-table th { text-align: left; padding: 10px; background: #f8fafc; font-size: 0.8rem; color: #64748b; }
.edit-table td { padding: 8px; border-bottom: 1px solid #f1f5f9; }
.edit-table input { width: 100%; padding: 6px; border: 1px solid #e2e8f0; border-radius: 4px; outline: none; text-align: center; }
.btn-del { color: #ef4444; background: none; border: none; font-size: 0.75rem; cursor: pointer; }
.btn-add-row { width: 100%; padding: 10px; border: 2px dashed #e2e8f0; border-radius: 8px; background: none; color: #94a3b8; cursor: pointer; font-weight: 600; margin-top: 10px; }
.btn-add-row:hover { border-color: #2563eb; color: #2563eb; }
.info-alert { background: #eff6ff; color: #1e40af; padding: 10px 15px; border-radius: 8px; margin-bottom: 1.5rem; font-size: 0.85rem; border-left: 4px solid #2563eb; }
.form-group label { display: block; margin-bottom: 0.5rem; font-size: 0.85rem; color: #475569; font-weight: 600; }
.form-group input { width: 100%; padding: 10px; border: 1px solid #e2e8f0; border-radius: 8px; outline: none; }
.mt-4 { margin-top: 1rem; }
</style>
@@ -0,0 +1,122 @@
<script setup>
import { defineProps, defineEmits } from 'vue'
import apiClient from '../../api/client'
const props = defineProps({
exposureStatus: Array
})
const emit = defineEmits(['update:exposureStatus'])
const addItem = () => {
const list = [...props.exposureStatus]
list.push({ keyword: '', image: '' })
emit('update:exposureStatus', list)
}
const removeItem = (idx) => {
const list = [...props.exposureStatus]
list.splice(idx, 1)
emit('update:exposureStatus', list)
}
const clearAll = () => {
if (confirm('전체 데이터를 삭제하시겠습니까?')) {
emit('update:exposureStatus', [])
}
}
const handleImage = async (idx, event) => {
const file = event.target.files[0]
if (file) {
const formData = new FormData()
formData.append('image', file)
try {
const res = await apiClient.post('/api/upload', formData)
const data = await res.json()
if (data.imageUrl) {
const list = [...props.exposureStatus]
list[idx].image = data.imageUrl
emit('update:exposureStatus', list)
}
} catch (err) {
console.error('Image upload failed:', err)
alert('이미지 업로드에 실패했습니다.')
}
}
}
const getFullUrl = (url) => {
if (!url) return ''
if (url.startsWith('data:') || url.startsWith('http')) return url
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
return `${baseUrl}${url}`
}
const triggerUpload = (idx) => {
document.getElementById(`exposure-upload-${idx}`).click()
}
</script>
<template>
<div class="exposure-editor">
<header class="tab-header-dark">
<h3>3. 상위 노출 현황</h3>
<div class="header-actions">
<button class="btn-action-add" @click="addItem">+ 키워드/이미지 추가</button>
<button class="btn-action-clear" @click="clearAll">전체 삭제</button>
</div>
</header>
<div class="exposure-grid">
<div v-for="(item, idx) in exposureStatus" :key="idx" class="exposure-card">
<div class="card-top">
<label>키워드</label>
<input v-model="item.keyword" placeholder="키워드 입력" class="keyword-input" @input="emit('update:exposureStatus', [...exposureStatus])">
</div>
<div class="image-dropzone" @click="triggerUpload(idx)">
<div v-if="item.image" class="preview-img" :style="`background-image: url(${getFullUrl(item.image)})`"></div>
<div v-else class="dropzone-placeholder">
<p>여기에 상위노출 캡쳐 이미지를 첨부하세요</p>
</div>
<input :id="`exposure-upload-${idx}`" type="file" class="hidden-file-input" accept="image/*"
@change="handleImage(idx, $event)">
</div>
<div class="card-footer">
<button class="btn-upload-trigger" @click="triggerUpload(idx)">이미지 첨부</button>
<button class="btn-remove-item" @click="removeItem(idx)">삭제</button>
</div>
</div>
<div v-if="exposureStatus.length === 0" class="empty-exposure">
<p>추가 버튼을 눌러 키워드와 이미지를 등록해 주세요.</p>
</div>
</div>
</div>
</template>
<style scoped>
.exposure-editor { display: flex; flex-direction: column; gap: 20px; }
.tab-header-dark { display: flex; justify-content: space-between; align-items: center; background: #0f3453; padding: 12px 20px; border-radius: 8px; color: white; }
.tab-header-dark h3 { font-size: 1rem; margin: 0; font-weight: 600; }
.header-actions { display: flex; gap: 10px; }
.btn-action-add { background: white; color: #0f3453; border: 1px solid #0f3453; padding: 6px 14px; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 0.85rem; }
.btn-action-clear { background: white; color: #ef4444; border: 1px solid #ef4444; padding: 6px 14px; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 0.85rem; }
.exposure-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; max-height: 600px; overflow-y: auto; padding: 10px; }
.exposure-card { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 15px; display: flex; flex-direction: column; gap: 12px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); }
.card-top { display: flex; align-items: center; gap: 10px; }
.card-top label { font-size: 0.85rem; font-weight: 600; color: #64748b; white-space: nowrap; }
.keyword-input { flex: 1; border: 1px solid #e2e8f0; padding: 8px 12px; border-radius: 6px; font-size: 0.85rem; font-weight: 700; outline: none; }
.image-dropzone { flex: 1; min-height: 200px; border: 2px dashed #e2e8f0; border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; position: relative; overflow: hidden; background: #f8fafc; }
.image-dropzone:hover { border-color: #2563eb; }
.dropzone-placeholder { text-align: center; color: #94a3b8; font-size: 0.85rem; padding: 20px; }
.preview-img { width: 100%; height: 100%; min-height: 240px; background-size: contain; background-repeat: no-repeat; background-position: center; }
.hidden-file-input { display: none; }
.card-footer { display: flex; justify-content: space-between; align-items: center; }
.btn-upload-trigger { background: #eff6ff; color: #2563eb; border: none; padding: 8px 16px; border-radius: 6px; font-size: 0.8rem; font-weight: 600; cursor: pointer; }
.btn-remove-item { background: white; color: #ef4444; border: 1px solid #fee2e2; padding: 8px 16px; border-radius: 6px; font-size: 0.8rem; font-weight: 600; cursor: pointer; }
.empty-exposure { grid-column: span 2; text-align: center; padding: 60px; color: #94a3b8; }
</style>
@@ -0,0 +1,228 @@
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
inflowData: Object
})
const emit = defineEmits(['update:inflowData'])
const addRow = () => {
const table = props.inflowData.table || []
table.push({ month: '', label: '', value: '', diff: '', isUp: true })
emit('update:inflowData', { ...props.inflowData, table })
}
const removeRow = (idx) => {
const table = props.inflowData.table || []
table.splice(idx, 1)
emit('update:inflowData', { ...props.inflowData, table })
}
const populateDefaults = () => {
const defaults = ['네이버 통합', '네이버 모바일', '네이버 PC', '다음', '구글', '기타', '인스타그램']
const table = props.inflowData.table || []
defaults.forEach(label => {
if (!table.some(item => item.label === label)) {
table.push({ month: '', label, value: '', diff: '', isUp: true })
}
})
emit('update:inflowData', { ...props.inflowData, table })
}
const updateDesc = (val) => {
emit('update:inflowData', { ...props.inflowData, description: val })
}
const handleTableKeydown = (e) => {
const key = e.key
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return
const input = e.target
if (['INPUT', 'TEXTAREA'].includes(input.tagName)) {
if (input.type === 'text' || input.type === 'number' || input.type === '' || input.tagName === 'TEXTAREA') {
if (key === 'ArrowLeft' && input.selectionStart > 0) return
if (key === 'ArrowRight' && input.selectionEnd < (input.value || '').length) return
if (key === 'Enter' && e.shiftKey) return
}
}
const td = input.closest('td')
if (!td) return
const tr = td.closest('tr')
const tbody = tr.closest('tbody')
if (!tbody) return
let colIdx = Array.from(tr.children).indexOf(td)
let rowIdx = Array.from(tbody.children).indexOf(tr)
let targetInput = null
while (!targetInput) {
if (key === 'ArrowUp') rowIdx--
else if (key === 'ArrowDown' || key === 'Enter') rowIdx++
else if (key === 'ArrowLeft') colIdx--
else if (key === 'ArrowRight') colIdx++
const targetTr = tbody.children[rowIdx]
if (!targetTr) return
const targetTd = targetTr.children[colIdx]
if (!targetTd) return
targetInput = targetTd.querySelector('input:not([type="hidden"]), select, textarea')
}
if (targetInput) {
e.preventDefault()
targetInput.focus()
if (targetInput.tagName === 'INPUT' || targetInput.tagName === 'TEXTAREA') {
targetInput.select()
}
}
}
</script>
<template>
<div class="inflow-reporting">
<div class="form-group full mb-6">
<label>유입 분석 요약 설명 (Radar 차트 표시)</label>
<textarea :value="inflowData.description" @input="updateDesc($event.target.value)" class="styled-textarea"
placeholder="유입 경로 성과에 대한 설명을 입력하세요." style="height: 100px;"></textarea>
</div>
<table class="edit-table" @keydown="handleTableKeydown">
<thead>
<tr>
<th width="120">측정월</th>
<th>경로명</th>
<th>비중(%)</th>
<th>대비</th>
<th width="80" style="text-align: center;">기타 합산</th>
<th width="60"></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in inflowData.table" :key="idx" :class="{ 'row-other': item.isOther }">
<td><input type="month" v-model="item.month" @input="emit('update:inflowData', { ...inflowData })"></td>
<td><textarea v-model="item.label" placeholder="네이버 검색" class="table-textarea"></textarea></td>
<td><input v-model="item.value" placeholder="45"></td>
<td><input v-model="item.diff" placeholder="50%"></td>
<td style="text-align: center;">
<input type="checkbox" v-model="item.isOther">
</td>
<td><button @click="removeRow(idx)" class="btn-del">삭제</button></td>
</tr>
</tbody>
</table>
<div class="row-actions">
<button class="btn-add-row" @click="addRow">+ 추가</button>
<button class="btn-add-row outline" @click="populateDefaults">기본 카테고리 추가 (스케치 기준)</button>
</div>
</div>
</template>
<style scoped>
.edit-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.edit-table th {
text-align: left;
padding: 10px;
background: #f8fafc;
font-size: 0.8rem;
color: #64748b;
}
.edit-table td {
padding: 8px;
border-bottom: 1px solid #f1f5f9;
}
.edit-table input {
width: 100%;
padding: 6px;
border: 1px solid #e2e8f0;
border-radius: 4px;
outline: none;
}
.table-textarea {
width: 100%;
padding: 6px;
border: 1px solid #e2e8f0;
border-radius: 4px;
outline: none;
resize: none;
min-height: 46px;
font-family: inherit;
font-size: inherit;
}
.btn-del {
color: #ef4444;
background: none;
border: none;
font-size: 0.75rem;
cursor: pointer;
}
.btn-add-row {
width: 100%;
padding: 10px;
border: 2px dashed #e2e8f0;
border-radius: 8px;
background: none;
color: #94a3b8;
cursor: pointer;
font-weight: 600;
margin-top: 10px;
}
.btn-add-row.outline {
border-style: solid;
border-color: #2563eb;
color: #2563eb;
margin-left: 10px;
width: auto;
flex: 1;
}
.row-actions {
display: flex;
gap: 10px;
}
.row-other {
background: #f8fafc;
opacity: 0.8;
}
.row-other input {
color: #94a3b8;
}
.styled-textarea {
width: 100%;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
resize: none;
margin-bottom: 20px;
outline: none;
}
.styled-textarea:focus {
border-color: #2563eb;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: #475569;
font-weight: 600;
}
</style>
@@ -0,0 +1,26 @@
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
nextMonthPlan: String
})
const emit = defineEmits(['update:nextMonthPlan'])
</script>
<template>
<div class="plan-reporting">
<label>계획 내용</label>
<textarea
:value="nextMonthPlan"
@input="emit('update:nextMonthPlan', $event.target.value)"
class="full-textarea"
placeholder="다음 달 마케팅 전략을 입력하세요."></textarea>
</div>
</template>
<style scoped>
.plan-reporting label { display: block; margin-bottom: 0.5rem; font-size: 0.85rem; color: #475569; font-weight: 600; }
.full-textarea { width: 100%; height: 400px; padding: 1.5rem; border: 1px solid #e2e8f0; border-radius: 12px; resize: none; outline: none; font-size: 1rem; line-height: 1.6; }
.full-textarea:focus { border-color: #2563eb; }
</style>
@@ -0,0 +1,94 @@
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
comparisonData: Object,
diagnosisResult: String,
reportSummary: String,
reportRound: String
})
const emit = defineEmits(['update:comparisonData', 'update:diagnosisResult', 'update:reportSummary'])
const updateCompData = (key, val) => {
emit('update:comparisonData', { ...props.comparisonData, [key]: val })
}
</script>
<template>
<div class="summary-reporting">
<div class="info-alert">리포트 하단에 표시될 성과 비교 수치와 종합 요약을 입력하세요.</div>
<div class="form-grid mb-6">
<div class="form-group" v-for="(val, key) in comparisonData" :key="key">
<label>{{ key }} 비교</label>
<input :value="val" @input="updateCompData(key, $event.target.value)" placeholder="예: 지난달 대비 15% 상승">
</div>
</div>
<div class="form-group full mb-6">
<label>{{ reportRound ? (reportRound.includes('(') ? reportRound : '(' + reportRound + ')') : '' }} 진단결과 (성과 비교 분석 하단 표기)</label>
<textarea :value="diagnosisResult" @input="emit('update:diagnosisResult', $event.target.value)"
class="full-textarea" placeholder="진단결과를 입력하세요." style="height: 120px;"></textarea>
</div>
<div class="form-group full">
<label>종합 리포트 결과 (강조 문구)</label>
<textarea :value="reportSummary" @input="emit('update:reportSummary', $event.target.value)" class="full-textarea"
placeholder="리포트의 결론을 한 문장으로 입력하세요." style="height: 150px;"></textarea>
</div>
</div>
</template>
<style scoped>
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.form-group.full {
grid-column: span 2;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: #475569;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
outline: none;
}
.full-textarea {
width: 100%;
height: 150px;
padding: 1rem;
border: 1px solid #e2e8f0;
border-radius: 12px;
resize: none;
font-size: 1rem;
}
.full-textarea:focus {
border-color: #2563eb;
}
.info-alert {
background: #eff6ff;
color: #1e40af;
padding: 10px 15px;
border-radius: 8px;
margin-bottom: 1.5rem;
font-size: 0.85rem;
border-left: 4px solid #2563eb;
}
.mb-6 {
margin-bottom: 1.5rem;
}
</style>
@@ -0,0 +1,67 @@
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
transferInfo: Object
})
const emit = defineEmits(['update:transferInfo'])
const updateInfo = (key, val) => {
emit('update:transferInfo', { ...props.transferInfo, [key]: val })
}
</script>
<template>
<div class="form-grid">
<div class="form-group full">
<h4 class="form-sub-title">1. 우리 회사 정보</h4>
</div>
<div class="form-group">
<label>회사명</label>
<input :value="transferInfo.companyName" @input="updateInfo('companyName', $event.target.value)" placeholder="(주)이지엠앤씨">
</div>
<div class="form-group">
<label>사업자 번호</label>
<input :value="transferInfo.businessNumber" @input="updateInfo('businessNumber', $event.target.value)" placeholder="210-81-78652">
</div>
<div class="form-group">
<label>대표자</label>
<input :value="transferInfo.ceo" @input="updateInfo('ceo', $event.target.value)" placeholder="김용수">
</div>
<div class="form-group">
<label>담당자</label>
<input :value="transferInfo.ourManager" @input="updateInfo('ourManager', $event.target.value)" placeholder="박민준, 유경미">
</div>
<div class="form-group full mt-4">
<h4 class="form-sub-title">2. 입금 결제 정보</h4>
</div>
<div class="form-group">
<label>은행명</label>
<input :value="transferInfo.bankName" @input="updateInfo('bankName', $event.target.value)" placeholder="우리은행">
</div>
<div class="form-group">
<label>계좌번호</label>
<input :value="transferInfo.account" @input="updateInfo('account', $event.target.value)" placeholder="1005-704-557550">
</div>
<div class="form-group">
<label>예금주</label>
<input :value="transferInfo.holder" @input="updateInfo('holder', $event.target.value)" placeholder="(주)이지엠앤씨">
</div>
<div class="form-group">
<label>입금 금액</label>
<input :value="transferInfo.amount" @input="updateInfo('amount', $event.target.value)" placeholder="금액 입력">
</div>
</div>
</template>
<style scoped>
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem; }
.form-group.full { grid-column: span 2; }
.form-group label { display: block; margin-bottom: 0.5rem; font-size: 0.85rem; color: #475569; font-weight: 600; }
.form-group input { width: 100%; padding: 10px; border: 1px solid #e2e8f0; border-radius: 8px; outline: none; }
.form-group input:focus { border-color: #2563eb; }
.form-sub-title { font-size: 1rem; font-weight: 700; color: #1e293b; margin-top: 1rem; border-left: 4px solid #2563eb; padding-left: 10px; }
.mt-4 { margin-top: 1rem; }
</style>
@@ -0,0 +1,214 @@
<script setup>
import { ref, watch, computed, defineProps, defineEmits } from 'vue'
import { Calendar } from 'lucide-vue-next'
const props = defineProps({
visitorData: Object
})
const emit = defineEmits(['update:visitorData'])
const startDatePicker = ref(null)
const endDatePicker = ref(null)
const tempStartDate = ref(props.visitorData.startDate || '')
const tempEndDate = ref(props.visitorData.endDate || '')
// 필터링 및 정렬된 테이블 데이터
const filteredTable = computed(() => {
const start = props.visitorData.startDate
const end = props.visitorData.endDate
if (!start && !end) return props.visitorData.table || []
return (props.visitorData.table || []).filter(item => {
if (!item.date) return true
const isAfterStart = !start || item.date >= start
const isBeforeEnd = !end || item.date <= end
return isAfterStart && isBeforeEnd
})
})
const isAllSelected = computed(() => {
const items = filteredTable.value
return items.length > 0 && items.every(item => item.selected)
})
const toggleSelectAll = (event) => {
const checked = event.target.checked
filteredTable.value.forEach(item => { item.selected = checked })
emit('update:visitorData', { ...props.visitorData })
}
const addRow = () => {
const table = props.visitorData.table || []
table.push({
selected: true,
date: '',
value: '',
diff: '-',
isUp: true,
viewValue: '',
viewDiff: '-',
viewIsUp: true
})
emit('update:visitorData', { ...props.visitorData, table })
}
const removeRow = (item) => {
const table = props.visitorData.table || []
const idx = table.indexOf(item)
if (idx > -1) table.splice(idx, 1)
emit('update:visitorData', { ...props.visitorData, table })
}
// 통계 자동 계산
const calculateStats = () => {
const table = props.visitorData.table || []
table.forEach((current, idx) => {
const prev = table[idx - 1]
// 방문자
if (!current.value || !prev?.value) {
current.diff = '-'
current.isUp = true
} else {
const curVal = parseInt(String(current.value).replace(/,/g, ''))
const prevVal = parseInt(String(prev.value).replace(/,/g, ''))
if (!isNaN(curVal) && !isNaN(prevVal) && prevVal !== 0) {
const percent = ((curVal - prevVal) / prevVal * 100).toFixed(1)
current.isUp = curVal >= prevVal
current.diff = (current.isUp ? '▲ ' : '▼ ') + Math.abs(percent) + '%'
}
}
// 조회수
if (!current.viewValue || !prev?.viewValue) {
current.viewDiff = '-'
current.viewIsUp = true
} else {
const curVal = parseInt(String(current.viewValue).replace(/,/g, ''))
const prevVal = parseInt(String(prev.viewValue).replace(/,/g, ''))
if (!isNaN(curVal) && !isNaN(prevVal) && prevVal !== 0) {
const percent = ((curVal - prevVal) / prevVal * 100).toFixed(1)
current.viewIsUp = curVal >= prevVal
current.viewDiff = (current.viewIsUp ? '▲ ' : '▼ ') + Math.abs(percent) + '%'
}
}
})
emit('update:visitorData', { ...props.visitorData, table })
}
const sortAndRecalculate = () => {
const table = props.visitorData.table || []
table.sort((a, b) => {
if (!a.date) return 1
if (!b.date) return -1
return a.date.localeCompare(b.date)
})
calculateStats()
}
const handleDateBlur = (item) => {
if (!item.date) return
let val = item.date.replace(/[^0-9]/g, '')
const year = new Date().getFullYear()
if (val.length === 3) item.date = `${year}-0${val[0]}-${val.slice(1)}`
else if (val.length === 4) item.date = `${year}-${val.slice(0, 2)}-${val.slice(2)}`
else if (val.length === 7) item.date = `${val.slice(0, 4)}-0${val[4]}-${val.slice(5)}`
else if (val.length === 8) item.date = `${val.slice(0, 4)}-${val.slice(4, 6)}-${val.slice(6)}`
sortAndRecalculate()
}
const applyFilter = () => {
emit('update:visitorData', {
...props.visitorData,
startDate: tempStartDate.value,
endDate: tempEndDate.value
})
}
const clearFilter = () => {
tempStartDate.value = ''
tempEndDate.value = ''
applyFilter()
}
const openPicker = (type) => {
if (type === 'start') startDatePicker.value.showPicker()
else endDatePicker.value.showPicker()
}
</script>
<template>
<div class="visitor-reporting">
<div class="filter-config-box">
<div class="config-row">
<label>리포트 노출 기간</label>
<div class="date-filter-group">
<div class="date-input-wrapper">
<Calendar :size="18" class="calendar-icon" @click="openPicker('start')" />
<input type="date" v-model="tempStartDate" ref="startDatePicker" @change="applyFilter" class="date-filter-input">
</div>
<span>~</span>
<div class="date-input-wrapper">
<Calendar :size="18" class="calendar-icon" @click="openPicker('end')" />
<input type="date" v-model="tempEndDate" ref="endDatePicker" @change="applyFilter" class="date-filter-input">
</div>
<button class="btn-clear-filter" @click="clearFilter">필터 초기화</button>
</div>
</div>
<p class="config-help">달력을 눌러 기간을 선택하면, 해당 날짜 범위의 데이터가 리포트에 표기되도록 자동 체크됩니다.</p>
</div>
<div class="info-alert">데이터를 입력하면 <b>방문자</b> <b>조회수</b> 대한 증감 수치가 자동 계산됩니다.</div>
<table class="edit-table">
<thead>
<tr>
<th width="50"><input type="checkbox" :checked="isAllSelected" @change="toggleSelectAll"></th>
<th width="130">날짜</th>
<th>방문자 </th>
<th width="100">전일대비</th>
<th>조회수</th>
<th width="100">전일대비</th>
<th width="60"></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in filteredTable" :key="idx">
<td><input type="checkbox" v-model="item.selected" @change="emit('update:visitorData', { ...visitorData })"></td>
<td><input v-model="item.date" placeholder="2026-03-27" @blur="handleDateBlur(item)"></td>
<td><input v-model="item.value" placeholder="방문자" @input="calculateStats"></td>
<td :class="item.isUp ? 'text-up' : 'text-down'">{{ item.diff }}</td>
<td><input v-model="item.viewValue" placeholder="조회수" @input="calculateStats"></td>
<td :class="item.viewIsUp ? 'text-up' : 'text-down'">{{ item.viewDiff }}</td>
<td><button @click="removeRow(item)" class="btn-del">삭제</button></td>
</tr>
</tbody>
</table>
<button class="btn-add-row" @click="addRow">+ 추가</button>
</div>
</template>
<style scoped>
.edit-table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; }
.edit-table th { text-align: left; padding: 10px; background: #f8fafc; font-size: 0.8rem; color: #64748b; }
.edit-table td { padding: 8px; border-bottom: 1px solid #f1f5f9; }
.edit-table input { width: 100%; padding: 6px; border: 1px solid #e2e8f0; border-radius: 4px; outline: none; }
.btn-del { color: #ef4444; background: none; border: none; font-size: 0.75rem; cursor: pointer; }
.btn-add-row { width: 100%; padding: 10px; border: 2px dashed #e2e8f0; border-radius: 8px; background: none; color: #94a3b8; cursor: pointer; font-weight: 600; }
.btn-add-row:hover { border-color: #2563eb; color: #2563eb; }
.text-up { color: #ef4444; font-weight: 700; text-align: center; }
.text-down { color: #2563eb; font-weight: 700; text-align: center; }
.filter-config-box { background: #f8fafc; padding: 1.5rem; border-radius: 12px; margin-bottom: 2rem; border: 1px solid #e2e8f0; }
.config-row { display: flex; align-items: center; gap: 1.5rem; }
.config-row label { font-size: 0.85rem; font-weight: 700; width: 140px; }
.date-filter-group { display: flex; align-items: center; gap: 12px; }
.date-input-wrapper { display: flex; align-items: center; gap: 8px; background: #fff; border: 1px solid #e2e8f0; padding: 6px 12px; border-radius: 8px; }
.calendar-icon { color: #2563eb; cursor: pointer; }
.date-filter-input { border: none; background: transparent; font-size: 0.85rem; cursor: text; }
.date-filter-input::-webkit-calendar-picker-indicator { display: none; }
.btn-clear-filter { background: #f1f5f9; border: none; padding: 6px 12px; border-radius: 6px; font-size: 0.75rem; cursor: pointer; }
.config-help { margin-top: 8px; font-size: 0.75rem; color: #94a3b8; }
.info-alert { background: #eff6ff; color: #1e40af; padding: 10px 15px; border-radius: 8px; margin-bottom: 1.5rem; font-size: 0.85rem; border-left: 4px solid #2563eb; }
</style>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import "./style.css";
createApp(App).use(router).mount("#app");
+65
View File
@@ -0,0 +1,65 @@
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "../views/HomeView.vue";
import LoginView from "../views/LoginView.vue";
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/login",
name: "login",
component: LoginView,
},
{
path: "/",
name: "home",
component: HomeView,
meta: { requiresAuth: true },
},
{
path: "/ai-chat",
name: "ai-chat",
component: () => import("../views/AIChatView.vue"),
meta: { requiresAuth: true },
},
{
path: "/admin",
name: "admin",
component: () => import("../views/AdminView.vue"),
meta: { requiresAuth: true, requiresAdmin: true },
},
// 잘못된 경로는 홈으로 리다이렉트
{
path: "/:pathMatch(.*)*",
redirect: "/",
},
],
});
// 네비게이션 가드: 로그인 여부 및 관리자 권한 체크
router.beforeEach((to, from, next) => {
const token = localStorage.getItem("accessToken") || sessionStorage.getItem("accessToken");
const userJson = localStorage.getItem("user") || sessionStorage.getItem("user");
let user = null;
if (userJson) {
try {
user = JSON.parse(userJson);
} catch (e) {
console.error("User parsing failed:", e);
}
}
if (to.meta.requiresAuth && !token) {
next("/login");
} else if (to.path === "/login" && token) {
next("/");
} else if (to.meta.requiresAdmin && (!user || user.role !== 2)) {
alert("접근 권한이 없습니다. 상위 관리자만 진입할 수 있습니다.");
next("/");
} else {
next();
}
});
export default router;
+553
View File
@@ -0,0 +1,553 @@
:root {
--primary: #2563eb;
--primary-light: #eff6ff;
--secondary: #64748b;
--bg-main: #f8fafc;
--bg-card: #ffffff;
--text-main: #1e293b;
--text-muted: #64748b;
--border: #e2e8f0;
--success: #10b981;
--danger: #ef4444;
--sidebar-bg: #1e293b;
--sidebar-text: #f8fafc;
--radius: 12px;
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
/* 전역 스크롤바 숨김 */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
*::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
body {
font-family: 'Inter', 'Noto Sans KR', sans-serif;
background-color: var(--bg-main);
color: var(--text-main);
line-height: 1.5;
}
.app-container {
display: flex;
min-height: 100vh;
}
/* Sidebar Styling */
.sidebar {
width: 260px;
background-color: var(--sidebar-bg);
color: var(--sidebar-text);
padding: 24px;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header h2 {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 24px;
color: #fff;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.1);
border-radius: var(--radius);
margin-bottom: 32px;
}
.avatar {
width: 36px;
height: 36px;
background: var(--primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.sidebar-nav .nav-group {
margin-bottom: 24px;
}
.sidebar-nav h3 {
font-size: 0.75rem;
text-transform: uppercase;
color: #94a3b8;
margin-bottom: 12px;
letter-spacing: 0.05em;
}
.sidebar-nav ul {
list-style: none;
}
.sidebar-nav li {
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.2s;
font-size: 0.9rem;
margin-bottom: 4px;
}
.sidebar-nav li i {
width: 18px;
}
.sidebar-nav li:hover {
background: rgba(255, 255, 255, 0.05);
}
.sidebar-nav li.active {
background: var(--primary);
color: white;
}
.sidebar-footer {
margin-top: auto;
}
.btn-logout {
width: 100%;
padding: 10px;
background: transparent;
border: 1px solid rgba(255,255,255,0.1);
color: #fff;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
/* Main Content Area */
.main-content {
flex-grow: 1;
padding: 32px;
max-width: 1400px;
margin: 0 auto;
}
.main-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
}
.main-header h1 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 4px;
}
.breadcrumb {
font-size: 0.85rem;
color: var(--text-muted);
}
.header-right {
display: flex;
gap: 12px;
align-items: center;
}
.date-picker {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: white;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.9rem;
cursor: pointer;
}
.btn {
padding: 10px 20px;
border-radius: var(--radius);
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
border: none;
}
.btn-primary {
background-color: var(--primary);
color: white;
}
.btn-primary:hover {
background-color: #1d4ed8;
}
.btn-outline {
background: white;
border: 1px solid var(--border);
color: var(--text-main);
}
.btn-sm {
padding: 6px 12px;
font-size: 0.8rem;
}
.btn-pdf { background: #fee2e2; color: #b91c1c; border: 1px solid #fca5a5; }
.btn-excel { background: #dcfce7; color: #15803d; border: 1px solid #86efac; }
/* Dashboard Grid */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.card-full { grid-column: span 2; }
.card-half { grid-column: span 1; }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-header h3 {
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-main);
}
.badge {
padding: 4px 10px;
background: var(--primary-light);
color: var(--primary);
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
/* Tables */
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.data-table th {
text-align: center;
padding: 12px;
background: #f8fafc;
font-size: 0.85rem;
color: var(--text-muted);
border-bottom: 2px solid var(--border);
}
.data-table td {
padding: 16px 12px;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.text-up { color: var(--success); font-weight: 600; }
.text-down { color: var(--danger); font-weight: 600; }
.grid-2 {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 24px;
}
.chart-container {
height: 300px;
}
.chart-container-small {
height: 200px;
width: 200px;
margin: 0 auto;
display: block;
position: relative;
}
.chart-container-small canvas {
max-width: 100% !important;
max-height: 100% !important;
}
.section-desc {
margin-top: 16px;
font-size: 0.9rem;
color: var(--text-muted);
background: #f1f5f9;
padding: 12px;
border-radius: 8px;
border-left: 4px solid var(--primary);
}
/* Inflow specific */
.mt-4 { margin-top: 1rem; }
/* Exposure Status */
.keyword-status ul {
list-style: none;
}
.keyword-status li {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px dashed var(--border);
}
.rank-badge {
color: var(--primary);
font-weight: bold;
}
.image-preview {
display: flex;
gap: 12px;
}
.preview-box {
flex: 1;
text-align: center;
}
.preview-box .label {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 4px;
display: block;
}
.img-placeholder {
width: 100%;
aspect-ratio: 16/9;
background: #e2e8f0;
border-radius: 8px;
background-size: cover;
background-position: center;
}
/* Plan section */
.styled-textarea {
width: 100%;
height: 120px;
padding: 16px;
border-radius: 8px;
border: 1px solid var(--border);
font-family: inherit;
resize: none;
background: #f8fafc;
}
.styled-textarea:focus {
outline: none;
border-color: var(--primary);
background: #fff;
}
/* Transfer info */
.transfer-info-box {
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
padding: 20px;
border-radius: var(--radius);
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.info-row:last-child { margin-bottom: 0; }
.info-row .label { color: var(--secondary); font-size: 0.85rem; }
.info-row .value { font-weight: 600; }
.info-row .value.highlight { color: var(--primary); font-size: 1.25rem; }
/* Deposit Section */
.grid-deposit {
display: grid;
grid-template-columns: 300px 1fr;
gap: 24px;
}
.calendar-mini {
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.calendar-header {
text-align: center;
font-weight: bold;
margin-bottom: 12px;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
text-align: center;
font-size: 0.75rem;
}
.cal-day {
padding: 8px 0;
border-radius: 4px;
}
.cal-day.active { background: var(--primary); color: white; }
/* Company Info Grid */
.grid-info {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.info-group label {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 4px;
text-transform: uppercase;
}
.info-group p {
font-weight: 500;
}
.info-group a {
color: var(--primary);
text-decoration: none;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
animation: fadeIn 0.5s ease-out forwards;
}
/* Print Optimization */
/* ==========================================================================
Print Optimization (Standard A4)
========================================================================== */
@media print {
/* 1. 기본 인쇄 여백 설정 */
@page {
size: A4 portrait;
margin-top: 15mm !important;
margin-bottom: 10mm !important;
margin-left: 10mm !important;
margin-right: 10mm !important;
}
/* 2. 레이아웃 초기화 */
html, body, #app, .app-layout, .main-content, .dashboard-grid {
height: auto !important;
overflow: visible !important;
display: block !important;
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
background: white !important;
}
/* 3. 불필요 UI 숨김 */
.sidebar, .btn, .breadcrumb, .date-picker, .badge, .btn-sm, .card-actions, .main-header-actions {
display: none !important;
}
/* 4. 카드 및 콘텐츠 최적화 */
.card {
page-break-inside: avoid !important;
margin-bottom: 10px !important;
padding: 12px 16px !important;
border: 1px solid #f1f5f9 !important;
box-shadow: none !important;
}
/* 5. 비율 및 여백 압축 (2페이지 안착 목적) */
.chart-container {
height: 240px !important;
width: 100% !important;
}
.data-table td, .data-table th {
padding: 8px 10px !important;
font-size: 0.8rem !important;
}
canvas {
max-width: 100% !important;
height: auto !important;
}
* { color: #1e293b !important; }
}
/* 수동 인쇄 요소 숨김 */
.print-page-logo, .print-header, .print-footer {
display: none !important;
}
.empty-table-msg {
padding: 60px !important;
text-align: center;
color: #94a3b8;
background: #f8fafc;
font-size: 0.95rem;
border-radius: 0 0 12px 12px;
}
.empty-chart-msg {
height: 350px;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
color: #94a3b8;
border-radius: 12px;
border: 1px dashed #e2e8f0;
margin-bottom: 24px;
font-size: 0.95rem;
}
+518
View File
@@ -0,0 +1,518 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { Send, Save, MessageSquare, ChevronLeft } from 'lucide-vue-next'
import Sidebar from '../components/Sidebar.vue'
import apiClient from '../api/client'
const router = useRouter()
const companies = ref([])
const messages = ref([
{ role: 'assistant', text: '안녕하세요! 비즈니스 분석 및 전략 수립을 도와드리는 AI 어시스턴트입니다. 무엇을 도와드릴까요?' }
])
const userInput = ref('')
const loading = ref(false)
const historyRef = ref(null)
const fetchCompanies = async () => {
try {
const res = await apiClient.get('/api/companies')
if (res.ok) {
companies.value = await res.json()
}
} catch (err) {
console.error('Failed to fetch companies:', err)
}
}
const handleLogout = () => {
localStorage.removeItem('accessToken')
router.push('/login')
}
const sendMessage = async () => {
if (!userInput.value.trim() || loading.value) return
const userText = userInput.value
const provider = localStorage.getItem('ai_preferred_provider') === 'gpt' ? 'gpt' : 'gemini'
const geminiKey = localStorage.getItem('ai_gemini_key') || ''
const gptKey = localStorage.getItem('ai_gpt_key') || ''
messages.value.push({ role: 'user', text: userText })
userInput.value = ''
loading.value = true
await scrollToBottom()
try {
let aiResponse = ''
// AI 자동 명령 지침 (정리됨)
const systemInstruction = `
당신은 '강촌상사'의 비즈니스 데이터 관리 전문가입니다.
아래 명령어를 사용하여 시스템을 직접 제어할 수 있습니다.
반드시 답변 마지막 줄에 해당 형식을 포함해 주세요.
1. 업체 이름 변경: [COMMAND] RENAME: 기존이름 -> 새로운이름
2. 새로운 업체 추가: [COMMAND] CREATE: 신규업체명
예: "네, 경주 업체를 새롭게 추가했습니다. [COMMAND] CREATE: 경주"
`
if (provider === 'gemini') {
if (!geminiKey) throw new Error('Gemini API Key가 필요합니다.')
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${geminiKey}`
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: systemInstruction + "\n\n" + userText }] }]
})
})
if (!res.ok) throw new Error('Gemini API 오류')
const data = await res.json()
aiResponse = data.candidates[0].content.parts[0].text
} else if (provider === 'gpt') {
if (!gptKey) throw new Error('GPT API Key가 필요합니다.')
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${gptKey}`
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemInstruction },
{ role: 'user', content: userText }
]
})
})
if (!res.ok) throw new Error('OpenAI API 오류 (할당량 부족 등)')
const data = await res.json()
aiResponse = data.choices[0].message.content
}
// --- 명령어 감지 및 자동 실행 ---
// 1. RENAME 감지
const renameMatch = aiResponse.match(/\[COMMAND\] RENAME:\s*(.+)\s*->\s*(.+)/i)
if (renameMatch) {
const oldName = renameMatch[1].trim()
const newName = renameMatch[2].trim()
const updateRes = await apiClient.put('/api/reports/rename', { oldName, newName })
if (updateRes.ok) {
aiResponse = aiResponse.replace(/\[COMMAND\].*$/m, `\n\n✅ 시스템: '${oldName}' -> '${newName}' 변경 완료.`)
await fetchCompanies()
}
}
// 2. CREATE 감지
const createMatch = aiResponse.match(/\[COMMAND\] CREATE:\s*(.+)/i)
if (createMatch) {
const newName = createMatch[1].trim()
const createRes = await apiClient.post('/api/reports/add', { name: newName })
if (createRes.ok) {
aiResponse = aiResponse.replace(/\[COMMAND\].*$/m, `\n\n✅ 시스템: '${newName}' 업체 신규 등록 완료.`)
await fetchCompanies()
}
}
messages.value.push({ role: 'assistant', text: aiResponse })
} catch (err) {
console.error('AI Request Failed:', err)
messages.value.push({
role: 'assistant',
text: `오류: ${err.message}. 설정을 확인해 주세요.`
})
} finally {
loading.value = false
await scrollToBottom()
}
}
const saveConversation = async () => {
if (messages.value.length <= 1) {
alert('저장할 대화 내용이 없습니다.')
return
}
try {
const title = messages.value.find(m => m.role === 'user')?.text.substring(0, 20) || 'AI 대화'
const res = await apiClient.post('/api/ai/save', {
title: title + '...',
messages: messages.value
})
if (res.ok) {
alert('대화가 성공적으로 저장되었습니다.')
} else {
alert('저장 중 오류가 발생했습니다.')
}
} catch (err) {
alert('저장 실패: ' + err.message)
}
}
const scrollToBottom = async () => {
await nextTick()
if (historyRef.value) {
historyRef.value.scrollTop = historyRef.value.scrollHeight
}
}
const goBack = () => {
router.push('/')
}
const goToHome = () => {
router.push('/')
}
onMounted(() => {
fetchCompanies()
})
</script>
<template>
<div class="app-layout">
<Sidebar
:companies="companies"
@select="goToHome"
@edit="goToHome"
@refresh="fetchCompanies"
@change-view="goToHome"
@logout="handleLogout"
/>
<main class="main-content">
<div class="chat-container">
<!-- Header -->
<header class="chat-header">
<div class="header-left">
<button class="btn-back" @click="goBack">
<ChevronLeft :size="20" />
</button>
<h1>AI 비즈니스 어시스턴트</h1>
</div>
<button class="btn-save" @click="saveConversation">
<Save :size="18" /> 저장하기
</button>
</header>
<!-- Chat Area -->
<div class="chat-history" ref="historyRef">
<div v-for="(msg, idx) in messages" :key="idx" :class="['message-wrapper', msg.role]">
<div class="message">
<div class="avatar">{{ msg.role === 'user' ? 'U' : 'AI' }}</div>
<div class="content">
<p>{{ msg.text }}</p>
</div>
</div>
</div>
<div v-if="loading" class="message-wrapper assistant loading">
<div class="message">
<div class="avatar">AI</div>
<div class="content">
<div class="typing-indicator">
<span></span><span></span><span></span>
</div>
</div>
</div>
</div>
</div>
<!-- Input Area -->
<div class="chat-input-section">
<div class="chat-input-wrapper">
<textarea
v-model="userInput"
placeholder="비즈니스 전략이나 분석에 대해 물어보세요... (Shift+Enter 줄바꿈, Enter 전송)"
@keydown.enter.exact.prevent="sendMessage"
rows="1"
></textarea>
<button class="btn-send" @click="sendMessage" :disabled="loading || !userInput.trim()">
<Send :size="20" />
</button>
</div>
<p class="input-hint">Gemini 1.5 Flash 모델이 답변을 생성합니다.</p>
</div>
</div>
</main>
</div>
</template>
<style scoped>
.app-layout {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #f8fafc;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 1.5rem;
overflow: hidden;
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
background: white;
border-radius: 16px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05);
overflow: hidden;
max-width: 1000px;
margin: 0 auto;
width: 100%;
}
.chat-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
background: white;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.btn-back {
background: none;
border: none;
color: #64748b;
cursor: pointer;
padding: 4px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.btn-back:hover {
background: #f1f5f9;
color: #1e293b;
}
.chat-header h1 {
font-size: 1.25rem;
font-weight: 700;
color: #1e293b;
}
.btn-save {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: #f1f5f9;
color: #475569;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-save:hover {
background: #e2e8f0;
color: #1e293b;
}
.chat-history {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
background: #fdfeff;
}
.message-wrapper {
display: flex;
width: 100%;
}
.message-wrapper.user {
justify-content: flex-end;
}
.message {
display: flex;
gap: 12px;
max-width: 80%;
}
.user .message {
flex-direction: row-reverse;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.8rem;
flex-shrink: 0;
}
.assistant .avatar {
background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
color: white;
}
.user .avatar {
background: #e2e8f0;
color: #475569;
}
.content {
padding: 12px 16px;
border-radius: 14px;
font-size: 0.95rem;
line-height: 1.6;
white-space: pre-wrap;
}
.assistant .content {
background: #f1f5f9;
color: #1e293b;
border-top-left-radius: 2px;
}
.user .content {
background: #2563eb;
color: white;
border-top-right-radius: 2px;
}
.chat-input-section {
padding: 1.5rem;
border-top: 1px solid #e2e8f0;
background: white;
}
.chat-input-wrapper {
display: flex;
gap: 12px;
align-items: flex-end;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 8px 12px;
transition: border-color 0.2s;
}
.chat-input-wrapper:focus-within {
border-color: #2563eb;
background: white;
}
textarea {
flex: 1;
border: none;
background: transparent;
padding: 8px 4px;
font-size: 1rem;
line-height: 1.5;
resize: none;
color: #1e293b;
max-height: 200px;
}
textarea:focus {
outline: none;
}
.btn-send {
background: #2563eb;
color: white;
border: none;
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
margin-bottom: 2px;
}
.btn-send:hover:not(:disabled) {
background: #1d4ed8;
transform: scale(1.05);
}
.btn-send:disabled {
background: #cbd5e1;
cursor: not-allowed;
}
.input-hint {
margin-top: 8px;
font-size: 0.75rem;
color: #94a3b8;
text-align: center;
}
/* Typing Indicator */
.typing-indicator {
display: flex;
gap: 4px;
padding: 4px 0;
}
.typing-indicator span {
width: 6px;
height: 6px;
background: #94a3b8;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
/* Custom Scrollbar */
.chat-history::-webkit-scrollbar {
width: 6px;
}
.chat-history::-webkit-scrollbar-track {
background: transparent;
}
.chat-history::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}
.chat-history::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
</style>
+627
View File
@@ -0,0 +1,627 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { Shield, Settings, Users, FolderKanban, LogOut, KeyRound, Clock, Globe } from 'lucide-vue-next'
import Sidebar from '../components/Sidebar.vue'
import apiClient from '../api/client'
const router = useRouter()
const companies = ref([])
const users = ref([])
const reports = ref([])
const loginLogs = ref([])
// 탭 상태: 'reports' | 'password' | 'logs'
const activeTab = ref('reports')
// 비밀번호 변경 폼 상태
const pwdForm = ref({ targetUsername: '', newPassword: '' })
const isSubmittingPwd = ref(false)
// 날짜 포맷 함수 (YYYY-MM-DD HH:mm:ss)
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
const fetchCompanies = async () => {
try {
const res = await apiClient.get('/api/companies')
if (res.ok) {
companies.value = await res.json()
}
} catch (err) {
console.error('Failed to fetch companies:', err)
}
}
// 전체 리포트 이력 패치
const fetchReports = async () => {
try {
const res = await apiClient.get('/api/reports/all')
if (res.ok) {
reports.value = await res.json()
}
} catch (err) {
console.error('Failed to fetch reports:', err)
}
}
// 전체 사용자 정보 패치 (role 판별용)
const fetchUsers = async () => {
try {
const res = await apiClient.get('/api/users')
if (res.ok) {
users.value = await res.json()
}
} catch (err) {
console.error('Failed to fetch users:', err)
}
}
// 로그인 접속 IP 로그 패치
const fetchLoginLogs = async () => {
try {
const res = await apiClient.get('/api/admin/login-logs')
if (res.ok) {
loginLogs.value = await res.json()
}
} catch (err) {
console.error('Failed to fetch login logs:', err)
}
}
// 사용자명으로 사용자의 role을 알아내는 캐시 매핑 객체
const userRoleMap = computed(() => {
const map = {}
users.value.forEach(u => {
map[u.username] = { role: u.role, name: u.name }
})
return map
})
// 비밀번호 강제 변경 서브밋
const submitChangePassword = async () => {
if (!pwdForm.value.targetUsername || !pwdForm.value.newPassword) {
alert('모든 필드를 입력해 주세요.')
return
}
if (isSubmittingPwd.value) return
isSubmittingPwd.value = true
try {
const res = await apiClient.post('/api/auth/change-password', {
targetUsername: pwdForm.value.targetUsername,
newPassword: pwdForm.value.newPassword
})
const data = await res.json()
if (res.ok) {
alert(data.message || '비밀번호 변경이 성공적으로 완료되었습니다.')
pwdForm.value = { targetUsername: '', newPassword: '' }
} else {
alert(data.message || '변경 실패')
}
} catch (err) {
console.error(err)
alert('서버 요청 오류')
} finally {
isSubmittingPwd.value = false
}
}
const handleLogout = () => {
localStorage.removeItem('accessToken')
localStorage.removeItem('user')
sessionStorage.removeItem('accessToken')
sessionStorage.removeItem('user')
router.push('/login')
}
const goToHome = () => {
router.push('/')
}
const changeTab = (tab) => {
activeTab.value = tab
if (tab === 'reports') {
fetchReports()
fetchUsers()
} else if (tab === 'logs') {
fetchLoginLogs()
}
}
onMounted(() => {
fetchCompanies()
fetchReports()
fetchUsers()
})
</script>
<template>
<div class="app-layout">
<Sidebar
:companies="companies"
@select="goToHome"
@edit="goToHome"
@refresh="fetchCompanies"
@change-view="goToHome"
@logout="handleLogout"
/>
<main class="main-content">
<div class="admin-container">
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<Shield class="admin-icon" :size="24" />
<h1>최상위 관리자 통제 센터</h1>
</div>
<div class="header-right">
<span class="role-badge">Super Admin</span>
</div>
</header>
<!-- Tab Buttons -->
<div class="tab-navigation">
<button
:class="['tab-btn', { active: activeTab === 'reports' }]"
@click="changeTab('reports')"
>
<Clock :size="18" />
<span>보고서 작성 이력 (일반 관리자 감지)</span>
</button>
<button
:class="['tab-btn', { active: activeTab === 'password' }]"
@click="changeTab('password')"
>
<KeyRound :size="18" />
<span>임시 비밀번호 변경</span>
</button>
<button
:class="['tab-btn', { active: activeTab === 'logs' }]"
@click="changeTab('logs')"
>
<Globe :size="18" />
<span>로그인 접속 IP 기록</span>
</button>
</div>
<!-- Content Body -->
<div class="admin-body">
<!-- 1. 보고서 이력 조회 -->
<div v-if="activeTab === 'reports'" class="tab-content">
<div class="section-title">
<h2>보고서 작성 현황</h2>
<p>일반 관리자(Role 1) 관리자들이 작성/수정한 전체 리포트 이력 시각을 정밀 모니터링합니다.</p>
</div>
<div class="table-wrapper">
<table class="admin-table">
<thead>
<tr>
<th>업체 ID</th>
<th>업체명</th>
<th>작성자 아이디 (이름)</th>
<th>작성 권한 레벨</th>
<th>최종 작성/수정 일시</th>
</tr>
</thead>
<tbody>
<tr v-for="report in reports" :key="report.companyId">
<td class="td-bold">{{ report.companyId }}</td>
<td>{{ report.name || '미등록' }}</td>
<td>
<span class="user-id">{{ report.owner }}</span>
<span class="user-real-name" v-if="userRoleMap[report.owner]">
({{ userRoleMap[report.owner].name }})
</span>
</td>
<td>
<span
v-if="userRoleMap[report.owner] && userRoleMap[report.owner].role === 1"
class="badge badge-warning"
>
일반 관리자 (Role 1)
</span>
<span
v-else-if="userRoleMap[report.owner] && userRoleMap[report.owner].role === 2"
class="badge badge-success"
>
상위 관리자 (Role 2)
</span>
<span v-else class="badge badge-info">
일반 사용자
</span>
</td>
<td class="td-time">{{ formatDate(report.updatedAt) }}</td>
</tr>
<tr v-if="reports.length === 0">
<td colspan="5" class="td-empty">등록된 보고서 이력이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 2. 비밀번호 강제 변경 -->
<div v-if="activeTab === 'password'" class="tab-content">
<div class="section-title">
<h2>사용자 임시 비밀번호 변경</h2>
<p>특정 유저의 계정을 입력하고, 강제로 새로운 비밀번호를 해싱 저장하여 임시 비밀번호를 발급합니다.</p>
</div>
<div class="form-container">
<div class="form-card">
<div class="input-group">
<label for="target-user">대상 사용자 아이디 (Username)</label>
<input
id="target-user"
v-model="pwdForm.targetUsername"
type="text"
placeholder="예: admin, kyeongmi"
/>
</div>
<div class="input-group">
<label for="new-password">새로운 비밀번호 (New Password)</label>
<input
id="new-password"
v-model="pwdForm.newPassword"
type="password"
placeholder="새로운 비밀번호 입력"
/>
</div>
<button
class="btn-primary"
:disabled="isSubmittingPwd"
@click="submitChangePassword"
>
{{ isSubmittingPwd ? '수정 중...' : '비밀번호 강제 변경 및 저장' }}
</button>
</div>
</div>
</div>
<!-- 3. 로그인 접속 IP 기록 -->
<div v-if="activeTab === 'logs'" class="tab-content">
<div class="section-title">
<h2>접속 로그인 IP 로그 기록</h2>
<p>서버에 로그인한 모든 사용자들의 IP 주소와 접속 기기 환경(User Agent) 기록을 실시간으로 보관합니다.</p>
</div>
<div class="table-wrapper">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>사용자 아이디</th>
<th>로그인 IP 주소</th>
<th>브라우저 환경 (User Agent)</th>
<th>로그인 일시</th>
</tr>
</thead>
<tbody>
<tr v-for="log in loginLogs" :key="log.id">
<td>{{ log.id }}</td>
<td class="td-bold">{{ log.username }}</td>
<td class="td-ip">{{ log.ipAddress }}</td>
<td class="td-ua" :title="log.userAgent">{{ log.userAgent || '-' }}</td>
<td class="td-time">{{ formatDate(log.loginTime) }}</td>
</tr>
<tr v-if="loginLogs.length === 0">
<td colspan="5" class="td-empty">로그인 이력 기록이 존재하지 않습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<style scoped>
.app-layout {
display: flex;
width: 100vw;
height: 100vh;
background-color: #0f172a;
color: #f8fafc;
overflow: hidden;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
background-color: #0f172a;
}
.admin-container {
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 2.5rem;
box-sizing: border-box;
}
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 2rem;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.admin-icon {
color: #6366f1;
}
.admin-header h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
letter-spacing: -0.025em;
}
.role-badge {
background: linear-gradient(135deg, #a855f7 0%, #6366f1 100%);
color: white;
padding: 6px 14px;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 600;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
/* Tab Navigation */
.tab-navigation {
display: flex;
gap: 12px;
margin-bottom: 2.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding-bottom: 10px;
}
.tab-btn {
background: transparent;
border: none;
color: #94a3b8;
padding: 10px 18px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
border-radius: 8px;
transition: all 0.2s;
}
.tab-btn:hover {
background: rgba(255, 255, 255, 0.03);
color: #f8fafc;
}
.tab-btn.active {
background: rgba(99, 102, 241, 0.1);
color: #818cf8;
border: 1px solid rgba(99, 102, 241, 0.2);
}
/* Content Body */
.section-title {
margin-bottom: 2rem;
}
.section-title h2 {
font-size: 1.3rem;
color: #fff;
margin: 0 0 0.5rem 0;
}
.section-title p {
color: #64748b;
font-size: 0.9rem;
margin: 0;
}
/* Form Container */
.form-container {
display: flex;
justify-content: flex-start;
}
.form-card {
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 2.5rem;
width: 100%;
max-width: 500px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
}
.input-group {
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.input-group label {
font-size: 0.85rem;
font-weight: 600;
color: #94a3b8;
}
.input-group input {
padding: 12px 16px;
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #fff;
font-size: 0.95rem;
transition: all 0.2s;
}
.input-group input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
}
.btn-primary {
width: 100%;
padding: 12px;
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover {
background: #4f46e5;
transform: translateY(-1px);
}
.btn-primary:disabled {
background: #475569;
cursor: not-allowed;
transform: none;
}
/* Tables */
.table-wrapper {
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);
}
.admin-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.9rem;
}
.admin-table th {
background: #111827;
color: #94a3b8;
font-weight: 600;
padding: 14px 20px;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.admin-table td {
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
color: #cbd5e1;
}
.admin-table tr:hover td {
background: rgba(255, 255, 255, 0.015);
}
.td-bold {
font-weight: 600;
color: #fff;
}
.user-id {
font-weight: 600;
color: #f1f5f9;
}
.user-real-name {
color: #64748b;
font-size: 0.8rem;
margin-left: 4px;
}
.td-time {
font-family: monospace;
font-size: 0.85rem;
color: #94a3b8;
}
.td-ip {
font-family: monospace;
color: #38bdf8;
font-weight: 600;
}
.td-ua {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
color: #64748b;
}
.td-empty {
text-align: center;
padding: 3rem 0;
color: #64748b;
font-size: 0.95rem;
}
/* Badges */
.badge {
padding: 4px 10px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-warning {
background: rgba(245, 158, 11, 0.1);
color: #fbbf24;
border: 1px solid rgba(245, 158, 11, 0.2);
}
.badge-success {
background: rgba(16, 185, 129, 0.1);
color: #34d399;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.badge-info {
background: rgba(99, 102, 241, 0.1);
color: #818cf8;
border: 1px solid rgba(99, 102, 241, 0.2);
}
@media (max-width: 768px) {
.admin-container {
padding: 1.5rem;
}
.tab-navigation {
flex-wrap: wrap;
}
}
</style>
+488
View File
@@ -0,0 +1,488 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { TrendingUp, Award } from 'lucide-vue-next'
import DashboardHeader from '../components/DashboardHeader.vue'
import Sidebar from '../components/Sidebar.vue'
import VisitorSection from '../components/VisitorSection.vue'
import ViewSection from '../components/ViewSection.vue'
import InflowSection from '../components/InflowSection.vue'
import BlogSection from '../components/BlogSection.vue'
import ExposureSection from '../components/ExposureSection.vue'
import PlanSection from '../components/PlanSection.vue'
import DepositSection from '../components/DepositSection.vue'
import TransferSection from '../components/TransferSection.vue'
import CompanyInfoSection from '../components/CompanyInfoSection.vue'
import ManagementSection from '../components/ManagementSection.vue'
import apiClient from '../api/client'
const router = useRouter()
const companies = ref([])
const selectedCompany = ref({ name: '업체를 선택하세요' })
const reportData = ref(null)
const currentView = ref('dashboard') // dashboard, deposit-all, deposit-company, deposit-kmong, blog-total, company-total
const handleLogout = () => {
localStorage.removeItem('accessToken')
router.push('/login')
}
const fetchCompanies = async () => {
try {
const res = await apiClient.get('/api/companies')
if (res.ok) {
companies.value = await res.json()
}
} catch (err) {
console.error('Failed to fetch companies:', err)
}
}
const fetchReport = async (companyId) => {
try {
const res = await apiClient.get(`/api/report/${companyId}`)
if (res.ok) {
reportData.value = await res.json()
}
} catch (err) {
console.error('Failed to fetch report:', err)
}
}
const handleSelect = (company) => {
selectedCompany.value = company
currentView.value = 'dashboard'
fetchReport(company.companyId)
}
const handleChangeView = (view) => {
currentView.value = view
if (view === 'dashboard' && selectedCompany.value?.companyId) {
fetchReport(selectedCompany.value.companyId)
}
}
const currentBlogRound = computed(() => {
const table = reportData.value?.depositDetails?.table || []
let maxRound = 0
let found = false
for (const row of table) {
if ((row.service || '').includes('블로그')) {
const rNum = parseInt(String(row.round || '').replace(/[^0-9]/g, ''))
if (!isNaN(rNum) && rNum > maxRound) {
maxRound = rNum
found = true
}
}
}
return found ? maxRound : null
})
// 인쇄 및 화면 표지용 회차 (실제 회차 - 1)
const displayBlogRound = computed(() => currentBlogRound.value ? (currentBlogRound.value > 1 ? currentBlogRound.value - 1 : currentBlogRound.value) : null)
const filteredBlogStatus = computed(() => {
const status = reportData.value?.blogStatus || []
// 보고서 노출회차 설정이 있으면 사용, 없으면 데이터 기반의 최신 회차 사용
let reportRound = reportData.value?.blogReportRound || currentBlogRound.value
if (!reportRound) return status
return status.filter(item => {
return Number(item.round) === Number(reportRound)
})
})
const currentViewConfig = computed(() => {
const configs = {
'deposit-all': { title: '년도별 전체 (월별)', showExcel: true },
'deposit-kmong': { title: '크몽 입금액', showExcel: false },
'company-total': { title: '업체정보 통합 관리', showExcel: false }
}
return configs[currentView.value] || { title: '관리 도구', showExcel: false }
})
const cleanedDiagnosisResult = computed(() => {
return reportData.value?.diagnosisResult || ''
})
const cleanedReportSummary = computed(() => {
return reportData.value?.reportSummary || ''
})
const getGrowthRate = (sectionData) => {
const table = sectionData?.table || []
// date 혹은 month 키가 있고 value가 있는 데이터만 필터링
const validData = table.filter(d => (d.date || d.month) && (d.value !== undefined && d.value !== ''))
if (validData.length < 2) return null
// 날짜순(최신순) 정렬
const sorted = [...validData].sort((a, b) => {
const dateA = a.date || a.month || ''
const dateB = b.date || b.month || ''
return dateB.localeCompare(dateA)
})
const curVal = parseFloat(String(sorted[0].value || '').replace(/[^0-9.]/g, '')) || 0
const prevVal = parseFloat(String(sorted[1].value || '').replace(/[^0-9.]/g, '')) || 0
if (prevVal === 0) return '0.0'
const rate = ((curVal - prevVal) / prevVal * 100).toFixed(1)
const isUp = parseFloat(rate) >= 0
return `지난 달 대비 ${Math.abs(rate)}% ${isUp ? '상승' : '하락'}`
}
onMounted(async () => {
await fetchCompanies()
if (companies.value.length > 0) {
handleSelect(companies.value[0])
}
})
</script>
<template>
<div class="app-layout">
<Sidebar :companies="companies" @select="handleSelect" @refresh="fetchCompanies" @change-view="handleChangeView"
@logout="handleLogout" />
<main class="main-content">
<!-- 대시보드 리포트 -->
<template v-if="currentView === 'dashboard'">
<DashboardHeader :companyName="selectedCompany.name" :companyId="selectedCompany.companyId"
:companies="companies" :reportRound="displayBlogRound ? displayBlogRound + '회차' : ''"
@select-company="handleSelect" />
<div class="report-container">
<!-- 상단 2 레이아웃 (방문자수, 조회수) -->
<section class="top-row">
<VisitorSection :data="reportData?.visitorData" />
<ViewSection :data="reportData?.viewData" />
</section>
<!-- 중앙 레이아웃 (유입분석) -->
<section class="middle-row mt-8">
<InflowSection :data="reportData?.inflowData" />
</section>
<!-- 비교 분석 섹션 -->
<section class="comparison-row mt-8" v-if="reportData?.comparisonData">
<div class="card comparison-card">
<div class="card-header">
<h3>
<TrendingUp :size="20" /> 성과 비교 분석
</h3>
</div>
<div class="card-body">
<div class="comparison-grid">
<div v-for="(val, key) in reportData.comparisonData" :key="key" class="comparison-item">
<span class="comp-label">{{ key }}</span>
<p class="comp-text">
<template v-if="key === '방문자수'">
{{ getGrowthRate(reportData.visitorData) || val }}
</template>
<template v-else-if="key === '조회수'">
{{ getGrowthRate(reportData.viewData) || val }}
</template>
<template v-else>
{{ val }}
</template>
</p>
</div>
</div>
</div>
</div>
</section>
<!-- 진단결과 섹션 -->
<section class="diagnosis-row mt-6" v-if="reportData?.diagnosisResult">
<div class="card diagnosis-card compact-card">
<div class="card-header pt-2 pb-1">
<h3 class="text-sm" style="color: #0f172a !important; font-size: 0.9rem !important;">{{ displayBlogRound ? `(${displayBlogRound}회차)` : '' }} 진단결과</h3>
</div>
<div class="card-body">
<div class="diagnosis-content" style="white-space: pre-wrap !important;">
{{ cleanedDiagnosisResult }}
</div>
</div>
</div>
</section>
<!-- 리포트 결과 요약 섹션 -->
<section class="summary-row mt-8" v-if="reportData?.reportSummary">
<div class="card summary-card">
<div class="card-header">
<h3>
<Award :size="20" /> 종합 보고서 결과
</h3>
</div>
<div class="card-body">
<div class="summary-content" style="white-space: pre-wrap !important;">
{{ cleanedReportSummary }}
</div>
</div>
</div>
</section>
<!-- 기타 기존 섹션들 -->
<section class="other-sections mt-8">
<BlogSection :data="filteredBlogStatus" />
<ExposureSection :data="reportData?.exposureStatus" :round="displayBlogRound" />
<PlanSection :data="reportData?.nextMonthPlan" :round="displayBlogRound" />
<DepositSection :data="reportData?.depositDetails" />
<TransferSection :data="reportData?.transferInfo" />
<CompanyInfoSection :data="{ ...reportData?.companyInfo, name: reportData?.name }" />
</section>
</div>
</template>
<!-- 관리 전용 -->
<template v-else>
<ManagementSection :title="currentViewConfig.title" :type="currentView" :showExcel="currentViewConfig.showExcel"
:companies="companies" @refresh-companies="fetchCompanies" />
</template>
</main>
</div>
</template>
<style scoped>
.app-layout {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 2rem;
background: #f8fafc;
}
.report-container {
flex: 1;
overflow-y: auto;
padding-bottom: 2rem;
scrollbar-width: thin;
}
.top-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.mt-6 {
margin-top: 1.5rem;
}
.mt-8 {
margin-top: 2rem;
}
@media print {
.comparison-row {
margin-top: 1.5rem !important;
}
}
.comparison-card {
background: linear-gradient(to right, #ffffff, #f1f5f9);
border-left: 5px solid var(--primary);
}
.comparison-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 2rem;
}
.comparison-item:last-child {
grid-column: span 2;
}
/* 성과 비교 분석 css */
.comparison-item {
padding: 1.5rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
.comp-label {
display: block;
font-size: 0.8rem;
font-weight: 700;
color: var(--primary);
margin-bottom: 0.6rem;
}
.comp-text {
font-size: 0.85rem;
color: #334155;
line-height: 1.4;
}
.summary-card {
background: #0f172a;
color: white;
}
.summary-card h3 {
color: white !important;
}
.summary-content,
.diagnosis-content {
font-size: 0.85rem;
font-weight: 500;
text-align: left;
padding: 1.2rem 1.5rem;
letter-spacing: -0.02rem;
line-height: 1.6;
}
.diagnosis-line {
margin: 0 !important;
padding: 0 !important;
line-height: 1.6 !important;
white-space: normal;
}
.compact-card .card-header {
padding: 0.4rem 0.8rem !important;
}
.compact-card .card-body {
padding: 0 !important;
}
.mt-2 {
margin-top: 0.5rem !important;
}
.diagnosis-card {
background: white;
border: 2px solid #0f172a;
}
.diagnosis-card h3 {
color: #0f172a !important;
font-size: 0.9375rem;
font-weight: 700;
}
.other-sections {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
@media print {
.app-layout,
.main-content,
.report-container {
display: block !important;
height: auto !important;
overflow: visible !important;
padding: 0 !important;
}
.top-row {
display: grid !important;
grid-template-columns: repeat(2, 1fr) !important;
}
.other-sections {
display: block !important;
}
.comparison-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.comparison-item:last-child {
grid-column: span 2 !important;
}
.mt-6 {
margin-top: 0.75rem !important;
}
.comparison-row {
margin-top: 0.3rem !important;
}
.diagnosis-content,
.summary-content {
font-size: 0.75rem !important;
line-height: 1.4 !important;
letter-spacing: normal !important;
padding: 0.5rem 1rem !important;
}
.diagnosis-line {
margin: 0 !important;
padding: 0 !important;
line-height: 1.4 !important;
}
.diagnosis-card h3,
.summary-card h3 {
font-size: 0.8rem !important;
margin: 0 !important;
padding: 0 0 5px 0 !important;
}
.comparison-item {
padding: 12px 16px 12px 16px !important;
border-radius: 6px !important;
}
.comparison-grid {
gap: 10px !important;
}
/* 성과 비교 분석 제목 */
.comp-label {
font-size: 0.9375rem !important;
margin-bottom: 2px !important;
}
/* 성과 비교 분석 내용 */
.comp-text {
font-size: 0.8rem !important;
line-height: 1.4 !important;
margin-top: 0.25rem;
}
.comparison-card .card-header {
padding-bottom: 0 !important;
margin-bottom: 0 !important;
}
.comparison-card .card-body {
padding-top: 2px !important;
}
.comparison-card .card-header h3 {
font-size: 0.9rem !important;
margin-bottom: 0 !important;
}
.card-header {
padding: 5px 10px !important;
min-height: auto !important;
}
}
</style>
```
+310
View File
@@ -0,0 +1,310 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const loginData = ref({ username: '', password: '' })
const registerData = ref({ name: '', username: '', password: '', passwordConfirm: '' })
const isLoading = ref(false)
const isAutoLogin = ref(true)
const isLoginMode = ref(true)
onMounted(() => {
const token = localStorage.getItem('accessToken') || sessionStorage.getItem('accessToken')
if (token) {
// 이미 로그인된 상태라면 홈으로 이동
router.push('/')
}
})
const handleLogin = async () => {
if (isLoading.value) return
isLoading.value = true
try {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL || ''}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // 쿠키 저장을 위해 추가
body: JSON.stringify(loginData.value)
})
const data = await res.json()
if (res.ok) {
if (isAutoLogin.value) {
localStorage.setItem('accessToken', data.accessToken)
localStorage.setItem('user', JSON.stringify(data.user))
} else {
sessionStorage.setItem('accessToken', data.accessToken)
sessionStorage.setItem('user', JSON.stringify(data.user))
}
router.push('/')
} else {
alert(data.message || '로그인 실패')
}
} catch (err) {
console.error('Login error:', err)
alert('서버 연결 오류')
} finally {
isLoading.value = false
}
}
const handleRegister = async () => {
if (isLoading.value) return
if (!registerData.value.name || !registerData.value.username || !registerData.value.password) {
alert('모든 입력 항목을 채워주세요.')
return
}
if (registerData.value.password !== registerData.value.passwordConfirm) {
alert('비밀번호가 일치하지 않습니다.')
return
}
isLoading.value = true
try {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL || ''}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: registerData.value.name,
username: registerData.value.username,
password: registerData.value.password
})
})
const data = await res.json()
if (res.ok) {
alert('회원가입이 완료되었습니다. 로그인 해주세요.')
isLoginMode.value = true
registerData.value = { name: '', username: '', password: '', passwordConfirm: '' }
} else {
alert(data.message || data.error || '회원가입 실패')
}
} catch (err) {
console.error('Register error:', err)
alert('서버 발생 오류')
} finally {
isLoading.value = false
}
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="login-header">
<div class="logo-box">B</div>
<h2>{{ isLoginMode ? '로그인' : '회원가입' }}</h2>
<p>{{ isLoginMode ? '로그인을 해주시기 바랍니다.' : '새로운 계정을 생성합니다.' }}</p>
</div>
<div class="login-body" v-if="isLoginMode">
<div class="input-group">
<label>아이디</label>
<input v-model="loginData.username" type="text" placeholder="아이디 입력" @keyup.enter="handleLogin">
</div>
<div class="input-group">
<label>비밀번호</label>
<input v-model="loginData.password" type="password" placeholder="••••••••" @keyup.enter="handleLogin">
</div>
<div class="login-options">
<label class="checkbox-label">
<input type="checkbox" v-model="isAutoLogin">
<span>자동 로그인</span>
</label>
</div>
<button class="btn-login" :disabled="isLoading" @click="handleLogin">
{{ isLoading ? '로그인 중...' : '로그인' }}
</button>
</div>
<div class="login-body" v-else>
<div class="input-group">
<label>이름</label>
<input v-model="registerData.name" type="text" placeholder="이름 입력" @keyup.enter="handleRegister">
</div>
<div class="input-group">
<label>아이디</label>
<input v-model="registerData.username" type="text" placeholder="아이디 입력" @keyup.enter="handleRegister">
</div>
<div class="input-group">
<label>비밀번호</label>
<input v-model="registerData.password" type="password" placeholder="비밀번호 입력" @keyup.enter="handleRegister">
</div>
<div class="input-group">
<label>비밀번호 확인</label>
<input v-model="registerData.passwordConfirm" type="password" placeholder="비밀번호 재입력" @keyup.enter="handleRegister">
</div>
<button class="btn-login" :disabled="isLoading" @click="handleRegister">
{{ isLoading ? '처리 중...' : '회원가입' }}
</button>
</div>
<div class="login-footer">
<p v-if="isLoginMode">계정 정보가 없으신가요? <span @click="isLoginMode = false">회원가입</span></p>
<p v-else>이미 계정이 있으신가요? <span @click="isLoginMode = true">로그인</span></p>
<div class="hint" v-if="isLoginMode">테스트 계정: admin / admin123!</div>
</div>
</div>
</div>
</template>
<style scoped>
.login-page {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
font-family: 'Inter', 'Noto Sans KR', sans-serif;
}
.login-card {
background: white;
width: 100%;
max-width: 440px;
padding: 40px;
border-radius: 24px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 10px 10px -5px rgba(0, 0, 0, 0.02);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.logo-box {
width: 48px;
height: 48px;
background: #2563eb;
color: white;
font-size: 1.5rem;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
margin: 0 auto 16px;
}
.login-header h2 {
font-size: 1.25rem;
color: #1e293b;
font-weight: 700;
margin-bottom: 8px;
}
.login-header p {
font-size: 0.9rem;
color: #64748b;
}
.input-group {
margin-bottom: 20px;
text-align: left;
}
.input-group label {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: #475569;
margin-bottom: 8px;
}
.input-group input {
width: 100%;
padding: 12px 16px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 12px;
font-size: 1rem;
transition: all 0.2s;
}
.input-group input:focus {
outline: none;
background: white;
border-color: #2563eb;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1);
}
.login-options {
margin-bottom: 24px;
display: flex;
align-items: center;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: #64748b;
cursor: pointer;
user-select: none;
}
.checkbox-label input {
width: 16px;
height: 16px;
cursor: pointer;
}
.btn-login {
width: 100%;
padding: 14px;
background: #1e293b;
color: white;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 10px;
}
.btn-login:hover {
background: #0f172a;
transform: translateY(-1px);
}
.btn-login:disabled {
background: #94a3b8;
cursor: not-allowed;
transform: none;
}
.login-footer {
margin-top: 32px;
text-align: center;
border-top: 1px solid #f1f5f9;
padding-top: 24px;
}
.login-footer p {
font-size: 0.8rem;
color: #94a3b8;
}
.login-footer p span {
color: #64748b;
text-decoration: underline;
cursor: pointer;
}
.hint {
margin-top: 12px;
font-size: 0.75rem;
color: #cbd5e1;
background: #f8fafc;
padding: 8px;
border-radius: 8px;
}
</style>