작업계획서대로 진행
This commit is contained in:
222
frontend/admin/vcs-server/index.vue
Normal file
222
frontend/admin/vcs-server/index.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">
|
||||
<NuxtLink to="/admin" class="text-decoration-none text-muted me-2"><i class="bi bi-arrow-left"></i></NuxtLink>
|
||||
<i class="bi bi-git me-2"></i>VCS 서버 관리
|
||||
</h4>
|
||||
<button class="btn btn-primary" @click="openModal()">
|
||||
<i class="bi bi-plus-lg me-1"></i>서버 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px" class="text-center">No</th>
|
||||
<th>서버명</th>
|
||||
<th style="width: 80px">유형</th>
|
||||
<th>URL</th>
|
||||
<th style="width: 80px" class="text-center">상태</th>
|
||||
<th style="width: 100px">등록일</th>
|
||||
<th style="width: 100px" class="text-center">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="7" class="text-center py-4"><span class="spinner-border spinner-border-sm"></span></td>
|
||||
</tr>
|
||||
<tr v-else-if="servers.length === 0">
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox display-4"></i>
|
||||
<p class="mt-2 mb-0">등록된 VCS 서버가 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="(s, idx) in servers" :key="s.serverId">
|
||||
<td class="text-center">{{ idx + 1 }}</td>
|
||||
<td>
|
||||
<div class="fw-bold">{{ s.serverName }}</div>
|
||||
<small class="text-muted">{{ s.description || '' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="s.serverType === 'git'" class="badge bg-success">Git</span>
|
||||
<span v-else class="badge bg-info">SVN</span>
|
||||
</td>
|
||||
<td><code class="small">{{ s.serverUrl }}</code></td>
|
||||
<td class="text-center">
|
||||
<span v-if="s.isActive" class="badge bg-success">활성</span>
|
||||
<span v-else class="badge bg-secondary">비활성</span>
|
||||
</td>
|
||||
<td>{{ formatDate(s.createdAt) }}</td>
|
||||
<td class="text-center">
|
||||
<button class="btn btn-sm btn-outline-primary me-1" @click="openModal(s)">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="deleteServer(s)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 생성/수정 모달 -->
|
||||
<div class="modal fade" :class="{ show: showModal }" :style="{ display: showModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ isEdit ? 'VCS 서버 수정' : 'VCS 서버 추가' }}</h5>
|
||||
<button type="button" class="btn-close" @click="showModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">서버명 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="form.serverName" placeholder="예: GitHub, 사내 GitLab" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">유형 <span class="text-danger">*</span></label>
|
||||
<div class="btn-group w-100">
|
||||
<input type="radio" class="btn-check" name="serverType" id="typeGit" value="git" v-model="form.serverType">
|
||||
<label class="btn btn-outline-success" for="typeGit"><i class="bi bi-git me-1"></i>Git</label>
|
||||
<input type="radio" class="btn-check" name="serverType" id="typeSvn" value="svn" v-model="form.serverType">
|
||||
<label class="btn btn-outline-info" for="typeSvn"><i class="bi bi-code-slash me-1"></i>SVN</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">서버 URL <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="form.serverUrl" placeholder="https://github.com" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea class="form-control" v-model="form.description" rows="2"></textarea>
|
||||
</div>
|
||||
<div v-if="isEdit" class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="isActive" v-model="form.isActive">
|
||||
<label class="form-check-label" for="isActive">활성화</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="saveServer" :disabled="isSaving">
|
||||
{{ isSaving ? '저장 중...' : '저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
interface VcsServer {
|
||||
serverId: number
|
||||
serverName: string
|
||||
serverType: string
|
||||
serverUrl: string
|
||||
description: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const servers = ref<VcsServer[]>([])
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const showModal = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editServerId = ref<number | null>(null)
|
||||
|
||||
const form = ref({
|
||||
serverName: '',
|
||||
serverType: 'git',
|
||||
serverUrl: '',
|
||||
description: '',
|
||||
isActive: true
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) { router.push('/login'); return }
|
||||
await loadServers()
|
||||
})
|
||||
|
||||
async function loadServers() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<{ servers: VcsServer[] }>('/api/vcs-server/list', { query: { includeInactive: 'true' } })
|
||||
servers.value = res.servers || []
|
||||
} catch (e) { console.error(e) }
|
||||
finally { isLoading.value = false }
|
||||
}
|
||||
|
||||
function openModal(server?: VcsServer) {
|
||||
if (server) {
|
||||
isEdit.value = true
|
||||
editServerId.value = server.serverId
|
||||
form.value = {
|
||||
serverName: server.serverName,
|
||||
serverType: server.serverType,
|
||||
serverUrl: server.serverUrl,
|
||||
description: server.description || '',
|
||||
isActive: server.isActive
|
||||
}
|
||||
} else {
|
||||
isEdit.value = false
|
||||
editServerId.value = null
|
||||
form.value = { serverName: '', serverType: 'git', serverUrl: '', description: '', isActive: true }
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function saveServer() {
|
||||
if (!form.value.serverName.trim()) { alert('서버명을 입력해주세요.'); return }
|
||||
if (!form.value.serverUrl.trim()) { alert('서버 URL을 입력해주세요.'); return }
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (isEdit.value && editServerId.value) {
|
||||
await $fetch(`/api/vcs-server/${editServerId.value}/update`, { method: 'PUT', body: form.value })
|
||||
} else {
|
||||
await $fetch('/api/vcs-server/create', { method: 'POST', body: form.value })
|
||||
}
|
||||
showModal.value = false
|
||||
await loadServers()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteServer(server: VcsServer) {
|
||||
if (!confirm(`"${server.serverName}" 서버를 삭제하시겠습니까?`)) return
|
||||
try {
|
||||
await $fetch(`/api/vcs-server/${server.serverId}/delete`, { method: 'DELETE' })
|
||||
await loadServers()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
if (!d) return '-'
|
||||
return d.split('T')[0]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show { background: rgba(0, 0, 0, 0.5); }
|
||||
</style>
|
||||
102
frontend/forgot-password.vue
Normal file
102
frontend/forgot-password.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-key display-1 text-warning"></i>
|
||||
<h2 class="mt-3">비밀번호 찾기</h2>
|
||||
<p class="text-muted">등록된 정보를 입력하시면 임시 비밀번호를 발급해 드립니다.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!isComplete" class="card">
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">이메일</label>
|
||||
<input type="email" class="form-control" v-model="email" placeholder="example@gmail.com" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">이름</label>
|
||||
<input type="text" class="form-control" v-model="name" placeholder="홍길동" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">휴대폰 번호 <small class="text-muted">(선택)</small></label>
|
||||
<input type="tel" class="form-control" v-model="phone" placeholder="010-1234-5678" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning w-100" :disabled="isSubmitting">
|
||||
<span v-if="isSubmitting"><span class="spinner-border spinner-border-sm me-2"></span>처리 중...</span>
|
||||
<span v-else><i class="bi bi-envelope me-2"></i>임시 비밀번호 발급</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="card">
|
||||
<div class="card-body text-center py-4">
|
||||
<i class="bi bi-check-circle display-4 text-success mb-3"></i>
|
||||
<h5>임시 비밀번호가 발송되었습니다</h5>
|
||||
<p class="text-muted">{{ resultMessage }}</p>
|
||||
<NuxtLink to="/login" class="btn btn-primary mt-3">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>로그인하기
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-danger mt-3" v-if="errorMessage">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<NuxtLink to="/login" class="text-muted"><i class="bi bi-arrow-left me-1"></i>로그인으로 돌아가기</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const email = ref('')
|
||||
const name = ref('')
|
||||
const phone = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const isComplete = ref(false)
|
||||
const resultMessage = ref('')
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email.value || !name.value) return
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const res = await $fetch<{ message: string }>('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: { email: email.value, name: name.value, phone: phone.value || undefined }
|
||||
})
|
||||
resultMessage.value = res.message
|
||||
isComplete.value = true
|
||||
} catch (e: any) {
|
||||
errorMessage.value = e.data?.message || e.message || '요청 처리에 실패했습니다.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
padding: 2rem;
|
||||
}
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -96,6 +96,88 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추가 현황 카드 (TODO, 유지보수, 회의) -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<NuxtLink to="/todo" class="text-decoration-none">
|
||||
<div class="card h-100 border-warning">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="text-muted"><i class="bi bi-check2-square me-1"></i>TODO</span>
|
||||
<i class="bi bi-arrow-right text-muted"></i>
|
||||
</div>
|
||||
<div class="d-flex justify-content-around text-center">
|
||||
<div>
|
||||
<h4 class="mb-0 text-secondary">{{ stats.todo?.pending || 0 }}</h4>
|
||||
<small class="text-muted">대기</small>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="mb-0 text-primary">{{ stats.todo?.inProgress || 0 }}</h4>
|
||||
<small class="text-muted">진행</small>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="mb-0 text-success">{{ stats.todo?.completedThisWeek || 0 }}</h4>
|
||||
<small class="text-muted">금주완료</small>
|
||||
</div>
|
||||
<div v-if="stats.todo?.overdue > 0">
|
||||
<h4 class="mb-0 text-danger">{{ stats.todo?.overdue }}</h4>
|
||||
<small class="text-muted">지연</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<NuxtLink to="/maintenance" class="text-decoration-none">
|
||||
<div class="card h-100 border-info">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="text-muted"><i class="bi bi-tools me-1"></i>유지보수</span>
|
||||
<i class="bi bi-arrow-right text-muted"></i>
|
||||
</div>
|
||||
<div class="d-flex justify-content-around text-center">
|
||||
<div>
|
||||
<h4 class="mb-0 text-secondary">{{ stats.maintenance?.pending || 0 }}</h4>
|
||||
<small class="text-muted">대기</small>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="mb-0 text-primary">{{ stats.maintenance?.inProgress || 0 }}</h4>
|
||||
<small class="text-muted">진행</small>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="mb-0 text-success">{{ stats.maintenance?.completedThisWeek || 0 }}</h4>
|
||||
<small class="text-muted">금주완료</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<NuxtLink to="/meeting" class="text-decoration-none">
|
||||
<div class="card h-100 border-secondary">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>회의</span>
|
||||
<i class="bi bi-arrow-right text-muted"></i>
|
||||
</div>
|
||||
<div class="d-flex justify-content-around text-center">
|
||||
<div>
|
||||
<h4 class="mb-0 text-primary">{{ stats.meeting?.thisWeek || 0 }}</h4>
|
||||
<small class="text-muted">금주</small>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="mb-0 text-muted">{{ stats.meeting?.thisMonth || 0 }}</h4>
|
||||
<small class="text-muted">월간</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- 인원별 현황 -->
|
||||
<div class="col-lg-6">
|
||||
|
||||
@@ -7,58 +7,85 @@
|
||||
<p class="text-muted">로그인하여 시작하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 새 사용자 로그인 폼 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-person-plus me-2"></i>이메일로 로그인
|
||||
</div>
|
||||
<!-- 로그인 탭 -->
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" :class="{ active: loginMode === 'email' }" @click="loginMode = 'email'">
|
||||
<i class="bi bi-envelope me-1"></i>이메일 로그인
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" :class="{ active: loginMode === 'password' }" @click="loginMode = 'password'">
|
||||
<i class="bi bi-key me-1"></i>비밀번호 로그인
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 이메일+이름 로그인 폼 -->
|
||||
<div v-if="loginMode === 'email'" class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="handleLogin">
|
||||
<form @submit.prevent="handleEmailLogin">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Gmail 주소</label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
v-model="email"
|
||||
placeholder="example@gmail.com"
|
||||
required
|
||||
/>
|
||||
<input type="email" class="form-control" v-model="email" placeholder="example@gmail.com" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">이름</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="name"
|
||||
placeholder="홍길동"
|
||||
required
|
||||
/>
|
||||
<input type="text" class="form-control" v-model="name" placeholder="홍길동" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100" :disabled="isSubmitting">
|
||||
<span v-if="isSubmitting">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>로그인 중...
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>로그인
|
||||
</span>
|
||||
<span v-if="isSubmitting"><span class="spinner-border spinner-border-sm me-2"></span>로그인 중...</span>
|
||||
<span v-else><i class="bi bi-box-arrow-in-right me-2"></i>로그인</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 로그인 폼 -->
|
||||
<div v-if="loginMode === 'password'" class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="handlePasswordLogin">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">이메일</label>
|
||||
<input type="email" class="form-control" v-model="email" placeholder="example@gmail.com" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">비밀번호</label>
|
||||
<input type="password" class="form-control" v-model="password" placeholder="비밀번호" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100" :disabled="isSubmitting">
|
||||
<span v-if="isSubmitting"><span class="spinner-border spinner-border-sm me-2"></span>로그인 중...</span>
|
||||
<span v-else><i class="bi bi-box-arrow-in-right me-2"></i>로그인</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 소셜 로그인 구분선 -->
|
||||
<div class="d-flex align-items-center my-3">
|
||||
<hr class="flex-grow-1" /><span class="mx-3 text-muted small">또는</span><hr class="flex-grow-1" />
|
||||
</div>
|
||||
|
||||
<!-- Google 로그인 -->
|
||||
<a href="/api/auth/google" class="btn btn-outline-secondary w-100">
|
||||
<svg class="me-2" width="18" height="18" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||
Google로 로그인
|
||||
</a>
|
||||
|
||||
<!-- 비밀번호 찾기 -->
|
||||
<div class="text-center mt-3">
|
||||
<NuxtLink to="/forgot-password" class="text-muted small">비밀번호를 잊으셨나요?</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기존 사용자 선택 -->
|
||||
<div class="card" v-if="recentUsers.length > 0">
|
||||
<div class="card" v-if="recentUsers.length > 0 && loginMode === 'email'">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-clock-history me-2"></i>최근 로그인 사용자
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
<button
|
||||
v-for="user in recentUsers"
|
||||
:key="user.employeeId"
|
||||
<button v-for="user in recentUsers" :key="user.employeeId"
|
||||
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
||||
@click="handleSelectUser(user.employeeId)"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
@click="handleSelectUser(user.employeeId)" :disabled="isSubmitting">
|
||||
<div>
|
||||
<i class="bi bi-person-circle me-2 text-primary"></i>
|
||||
<strong>{{ user.employeeName }}</strong>
|
||||
@@ -78,35 +105,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: false
|
||||
})
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const { login, selectUser, getRecentUsers } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const loginMode = ref<'email' | 'password'>('email')
|
||||
const email = ref('')
|
||||
const name = ref('')
|
||||
const password = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const recentUsers = ref<any[]>([])
|
||||
|
||||
// 최근 로그인 사용자 불러오기
|
||||
onMounted(async () => {
|
||||
try {
|
||||
recentUsers.value = await getRecentUsers()
|
||||
} catch (e) {
|
||||
// 무시
|
||||
}
|
||||
} catch (e) {}
|
||||
})
|
||||
|
||||
// 이메일+이름 로그인
|
||||
async function handleLogin() {
|
||||
async function handleEmailLogin() {
|
||||
if (!email.value || !name.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await login(email.value, name.value)
|
||||
router.push('/')
|
||||
@@ -117,11 +138,26 @@ async function handleLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 사용자 선택
|
||||
async function handlePasswordLogin() {
|
||||
if (!email.value || !password.value) return
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await $fetch('/api/auth/login-password', {
|
||||
method: 'POST',
|
||||
body: { email: email.value, password: password.value }
|
||||
})
|
||||
router.push('/')
|
||||
} catch (e: any) {
|
||||
errorMessage.value = e.data?.message || e.message || '로그인에 실패했습니다.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectUser(employeeId: number) {
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await selectUser(employeeId)
|
||||
router.push('/')
|
||||
@@ -142,7 +178,6 @@ async function handleSelectUser(employeeId: number) {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
@@ -151,8 +186,6 @@ async function handleSelectUser(employeeId: number) {
|
||||
max-width: 450px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.list-group-item-action:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.nav-tabs .nav-link { cursor: pointer; }
|
||||
.list-group-item-action:hover { background-color: #f8f9fa; }
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
<i class="bi bi-tools me-2"></i>유지보수 업무
|
||||
</h4>
|
||||
<div>
|
||||
<NuxtLink to="/maintenance/stats" class="btn btn-outline-secondary me-2">
|
||||
<i class="bi bi-graph-up me-1"></i>통계
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/maintenance/upload" class="btn btn-outline-primary me-2">
|
||||
<i class="bi bi-upload me-1"></i>일괄 등록
|
||||
</NuxtLink>
|
||||
|
||||
278
frontend/maintenance/stats.vue
Normal file
278
frontend/maintenance/stats.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">
|
||||
<NuxtLink to="/maintenance" class="text-decoration-none text-muted me-2"><i class="bi bi-arrow-left"></i></NuxtLink>
|
||||
<i class="bi bi-graph-up me-2"></i>유지보수 통계
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-auto"><label class="col-form-label">년도</label></div>
|
||||
<div class="col-auto">
|
||||
<select class="form-select form-select-sm" v-model="filter.year" @change="loadStats">
|
||||
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto"><label class="col-form-label">월</label></div>
|
||||
<div class="col-auto">
|
||||
<select class="form-select form-select-sm" v-model="filter.month" @change="loadStats">
|
||||
<option value="">전체</option>
|
||||
<option v-for="m in 12" :key="m" :value="m">{{ m }}월</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto"><label class="col-form-label">프로젝트</label></div>
|
||||
<div class="col-3">
|
||||
<select class="form-select form-select-sm" v-model="filter.projectId" @change="loadStats">
|
||||
<option value="">전체</option>
|
||||
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">{{ p.projectName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 요약 카드 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card text-bg-primary">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="mb-1">{{ stats.summary?.total || 0 }}</h2>
|
||||
<div>전체</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-bg-success">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="mb-1">{{ stats.summary?.completed || 0 }}</h2>
|
||||
<div>완료</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-bg-warning">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="mb-1">{{ stats.summary?.inProgress || 0 }}</h2>
|
||||
<div>진행중</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-bg-secondary">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="mb-1">{{ stats.summary?.pending || 0 }}</h2>
|
||||
<div>대기</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- 월별 추이 -->
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><strong>월별 추이</strong></div>
|
||||
<div class="card-body">
|
||||
<canvas ref="monthlyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 유형별 -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><strong>유형별</strong></div>
|
||||
<div class="card-body">
|
||||
<canvas ref="typeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트별 -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>프로젝트별 TOP 10</strong></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>프로젝트</th>
|
||||
<th class="text-end">전체</th>
|
||||
<th class="text-end">완료</th>
|
||||
<th class="text-end">완료율</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in stats.byProject" :key="p.projectId">
|
||||
<td>{{ p.projectName }}</td>
|
||||
<td class="text-end">{{ p.total }}</td>
|
||||
<td class="text-end">{{ p.completed }}</td>
|
||||
<td class="text-end">{{ p.total > 0 ? Math.round(p.completed / p.total * 100) : 0 }}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 담당자별 -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>담당자별 TOP 10</strong></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>담당자</th>
|
||||
<th class="text-end">전체</th>
|
||||
<th class="text-end">완료</th>
|
||||
<th class="text-end">완료율</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in stats.byAssignee" :key="a.employeeId">
|
||||
<td>{{ a.employeeName }}</td>
|
||||
<td class="text-end">{{ a.total }}</td>
|
||||
<td class="text-end">{{ a.completed }}</td>
|
||||
<td class="text-end">{{ a.total > 0 ? Math.round(a.completed / a.total * 100) : 0 }}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
interface Project { projectId: number; projectName: string }
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const stats = ref<any>({})
|
||||
const isLoading = ref(true)
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years = [currentYear, currentYear - 1, currentYear - 2]
|
||||
|
||||
const filter = ref({
|
||||
year: currentYear,
|
||||
month: '',
|
||||
projectId: ''
|
||||
})
|
||||
|
||||
const monthlyChart = ref<HTMLCanvasElement | null>(null)
|
||||
const typeChart = ref<HTMLCanvasElement | null>(null)
|
||||
let monthlyChartInstance: Chart | null = null
|
||||
let typeChartInstance: Chart | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) { router.push('/login'); return }
|
||||
await loadProjects()
|
||||
await loadStats()
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await $fetch<{ projects: Project[] }>('/api/project/list')
|
||||
projects.value = res.projects || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<any>('/api/maintenance/stats', {
|
||||
query: {
|
||||
year: filter.value.year,
|
||||
month: filter.value.month || undefined,
|
||||
projectId: filter.value.projectId || undefined
|
||||
}
|
||||
})
|
||||
stats.value = res
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch (e) { console.error(e) }
|
||||
finally { isLoading.value = false }
|
||||
}
|
||||
|
||||
function renderCharts() {
|
||||
// 월별 추이 차트
|
||||
if (monthlyChart.value) {
|
||||
if (monthlyChartInstance) monthlyChartInstance.destroy()
|
||||
|
||||
const labels = Array.from({ length: 12 }, (_, i) => `${i + 1}월`)
|
||||
const totalData = new Array(12).fill(0)
|
||||
const completedData = new Array(12).fill(0)
|
||||
|
||||
for (const m of stats.value.monthlyTrend || []) {
|
||||
totalData[m.month - 1] = m.total
|
||||
completedData[m.month - 1] = m.completed
|
||||
}
|
||||
|
||||
monthlyChartInstance = new Chart(monthlyChart.value, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '전체', data: totalData, backgroundColor: 'rgba(54, 162, 235, 0.6)' },
|
||||
{ label: '완료', data: completedData, backgroundColor: 'rgba(75, 192, 192, 0.6)' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'top' } }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 유형별 차트
|
||||
if (typeChart.value) {
|
||||
if (typeChartInstance) typeChartInstance.destroy()
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
bug: '버그', feature: '기능', inquiry: '문의', other: '기타'
|
||||
}
|
||||
|
||||
const labels = (stats.value.byType || []).map((t: any) => typeLabels[t.taskType] || t.taskType)
|
||||
const data = (stats.value.byType || []).map((t: any) => t.count)
|
||||
|
||||
typeChartInstance = new Chart(typeChart.value, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
data,
|
||||
backgroundColor: ['#dc3545', '#0d6efd', '#ffc107', '#6c757d']
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'right' } }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -151,7 +151,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 로그인 이력 -->
|
||||
<div class="card">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<strong>로그인 이력</strong>
|
||||
<small class="text-muted ms-2">(최근 50건)</small>
|
||||
@@ -194,6 +194,149 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VCS 계정 설정 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-git me-2"></i>VCS 계정 설정</strong>
|
||||
<button class="btn btn-sm btn-outline-primary" @click="openVcsModal()">
|
||||
<i class="bi bi-plus"></i> 계정 추가
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div v-if="isLoadingVcs" class="text-center py-4">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>로딩 중...
|
||||
</div>
|
||||
<div v-else-if="vcsAccounts.length === 0" class="text-center py-4 text-muted">
|
||||
<i class="bi bi-git display-6 d-block mb-2"></i>
|
||||
등록된 VCS 계정이 없습니다.
|
||||
</div>
|
||||
<div v-else class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>서버</th>
|
||||
<th>사용자명</th>
|
||||
<th>이메일</th>
|
||||
<th style="width: 80px">인증방식</th>
|
||||
<th style="width: 100px" class="text-center">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in vcsAccounts" :key="a.accountId">
|
||||
<td>
|
||||
<span v-if="a.serverType === 'git'" class="badge bg-success me-1">Git</span>
|
||||
<span v-else class="badge bg-info me-1">SVN</span>
|
||||
{{ a.serverName }}
|
||||
</td>
|
||||
<td>{{ a.vcsUsername }}</td>
|
||||
<td>{{ a.vcsEmail || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="a.authType === 'token'" class="badge bg-primary">토큰</span>
|
||||
<span v-else-if="a.authType === 'ssh'" class="badge bg-dark">SSH</span>
|
||||
<span v-else class="badge bg-secondary">비밀번호</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<button class="btn btn-sm btn-outline-primary me-1" @click="openVcsModal(a)">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="deleteVcsAccount(a)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 변경 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<strong><i class="bi bi-key me-2"></i>비밀번호 변경</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="changePassword">
|
||||
<div class="row mb-3" v-if="userInfo.hasPassword">
|
||||
<label class="col-3 col-form-label">현재 비밀번호</label>
|
||||
<div class="col-9">
|
||||
<input type="password" class="form-control" v-model="pwForm.currentPassword" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">새 비밀번호</label>
|
||||
<div class="col-9">
|
||||
<input type="password" class="form-control" v-model="pwForm.newPassword" required minlength="8" />
|
||||
<small class="text-muted">8자 이상</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">비밀번호 확인</label>
|
||||
<div class="col-9">
|
||||
<input type="password" class="form-control" v-model="pwForm.confirmPassword" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<button type="submit" class="btn btn-warning" :disabled="isChangingPw">
|
||||
<span v-if="isChangingPw" class="spinner-border spinner-border-sm me-1"></span>
|
||||
비밀번호 변경
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VCS 계정 모달 -->
|
||||
<div class="modal fade" :class="{ show: showVcsModal }" :style="{ display: showVcsModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ isEditVcs ? 'VCS 계정 수정' : 'VCS 계정 추가' }}</h5>
|
||||
<button type="button" class="btn-close" @click="showVcsModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">VCS 서버 <span class="text-danger">*</span></label>
|
||||
<select class="form-select" v-model="vcsForm.serverId" :disabled="isEditVcs">
|
||||
<option value="">선택하세요</option>
|
||||
<option v-for="s in vcsServers" :key="s.serverId" :value="s.serverId.toString()">
|
||||
[{{ s.serverType.toUpperCase() }}] {{ s.serverName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">사용자명 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="vcsForm.vcsUsername" placeholder="VCS 계정 사용자명" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">이메일</label>
|
||||
<input type="email" class="form-control" v-model="vcsForm.vcsEmail" placeholder="커밋 시 사용하는 이메일" />
|
||||
<small class="text-muted">Git 커밋 시 사용하는 이메일과 동일하게 입력하세요.</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">인증 방식</label>
|
||||
<select class="form-select" v-model="vcsForm.authType">
|
||||
<option value="password">비밀번호</option>
|
||||
<option value="token">토큰</option>
|
||||
<option value="ssh">SSH 키</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ vcsForm.authType === 'token' ? '액세스 토큰' : '비밀번호/키' }}</label>
|
||||
<input type="password" class="form-control" v-model="vcsForm.credential"
|
||||
:placeholder="isEditVcs ? '변경하려면 입력' : ''" />
|
||||
<small class="text-muted">암호화하여 저장됩니다.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showVcsModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="saveVcsAccount">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showVcsModal"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -206,6 +349,15 @@ const userInfo = ref<any>({})
|
||||
const loginHistory = ref<any[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isLoadingHistory = ref(true)
|
||||
|
||||
// VCS 계정 관련
|
||||
const vcsAccounts = ref<any[]>([])
|
||||
const vcsServers = ref<any[]>([])
|
||||
const isLoadingVcs = ref(true)
|
||||
const showVcsModal = ref(false)
|
||||
const isEditVcs = ref(false)
|
||||
const editVcsId = ref<number | null>(null)
|
||||
const vcsForm = ref({ serverId: '', vcsUsername: '', vcsEmail: '', authType: 'password', credential: '' })
|
||||
const isEditing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
@@ -225,6 +377,8 @@ onMounted(async () => {
|
||||
}
|
||||
loadUserInfo()
|
||||
loadLoginHistory()
|
||||
loadVcsAccounts()
|
||||
loadVcsServers()
|
||||
})
|
||||
|
||||
async function loadUserInfo() {
|
||||
@@ -294,4 +448,67 @@ function formatDateTime(dateStr: string) {
|
||||
const second = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
||||
}
|
||||
|
||||
// VCS 계정 관련 함수
|
||||
async function loadVcsAccounts() {
|
||||
isLoadingVcs.value = true
|
||||
try {
|
||||
const res = await $fetch<any>('/api/vcs-account/my')
|
||||
vcsAccounts.value = res.accounts || []
|
||||
} catch (e) { console.error(e) }
|
||||
finally { isLoadingVcs.value = false }
|
||||
}
|
||||
|
||||
async function loadVcsServers() {
|
||||
try {
|
||||
const res = await $fetch<any>('/api/vcs-server/list')
|
||||
vcsServers.value = res.servers || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function openVcsModal(account?: any) {
|
||||
if (account) {
|
||||
isEditVcs.value = true
|
||||
editVcsId.value = account.accountId
|
||||
vcsForm.value = {
|
||||
serverId: account.serverId?.toString() || '',
|
||||
vcsUsername: account.vcsUsername || '',
|
||||
vcsEmail: account.vcsEmail || '',
|
||||
authType: account.authType || 'password',
|
||||
credential: ''
|
||||
}
|
||||
} else {
|
||||
isEditVcs.value = false
|
||||
editVcsId.value = null
|
||||
vcsForm.value = { serverId: '', vcsUsername: '', vcsEmail: '', authType: 'password', credential: '' }
|
||||
}
|
||||
showVcsModal.value = true
|
||||
}
|
||||
|
||||
async function saveVcsAccount() {
|
||||
if (!vcsForm.value.serverId) { alert('서버를 선택해주세요.'); return }
|
||||
if (!vcsForm.value.vcsUsername.trim()) { alert('사용자명을 입력해주세요.'); return }
|
||||
|
||||
try {
|
||||
if (isEditVcs.value && editVcsId.value) {
|
||||
await $fetch(`/api/vcs-account/${editVcsId.value}/update`, { method: 'PUT', body: vcsForm.value })
|
||||
} else {
|
||||
await $fetch('/api/vcs-account/create', { method: 'POST', body: { ...vcsForm.value, serverId: Number(vcsForm.value.serverId) } })
|
||||
}
|
||||
showVcsModal.value = false
|
||||
await loadVcsAccounts()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '저장에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVcsAccount(account: any) {
|
||||
if (!confirm('VCS 계정을 삭제하시겠습니까?')) return
|
||||
try {
|
||||
await $fetch(`/api/vcs-account/${account.accountId}/delete`, { method: 'DELETE' })
|
||||
await loadVcsAccounts()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적/계획</strong>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-warning" @click="openMaintenanceModal">
|
||||
<i class="bi bi-tools me-1"></i>유지보수 불러오기
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success" @click="showAiModal = true">
|
||||
<i class="bi bi-robot me-1"></i>AI 자동채우기
|
||||
</button>
|
||||
@@ -447,6 +450,83 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showAiModal" @click="closeAiModal"></div>
|
||||
|
||||
<!-- 유지보수 불러오기 모달 -->
|
||||
<div class="modal fade" :class="{ show: showMaintenanceModal }" :style="{ display: showMaintenanceModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-tools me-2"></i>유지보수 업무 불러오기</h5>
|
||||
<button type="button" class="btn-close" @click="showMaintenanceModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Step 1: 프로젝트 선택 -->
|
||||
<div v-if="maintenanceStep === 'select'">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">프로젝트 선택</label>
|
||||
<select class="form-select" v-model="maintenanceProjectId" @change="loadMaintenanceTasks">
|
||||
<option value="">선택하세요</option>
|
||||
<option v-for="p in allProjects" :key="p.projectId" :value="p.projectId">{{ p.projectName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="isLoadingMaintenance" class="text-center py-4">
|
||||
<div class="spinner-border spinner-border-sm"></div> 유지보수 업무 조회 중...
|
||||
</div>
|
||||
<div v-else-if="maintenanceTasks.length === 0 && maintenanceProjectId" class="alert alert-info">
|
||||
해당 주차에 완료된 유지보수 업무가 없습니다.
|
||||
</div>
|
||||
<div v-else-if="maintenanceTasks.length > 0">
|
||||
<div class="mb-2 text-muted small">{{ form.weekStartDate }} ~ {{ form.weekEndDate }} 완료 업무</div>
|
||||
<div class="list-group">
|
||||
<label v-for="t in maintenanceTasks" :key="t.taskId" class="list-group-item list-group-item-action">
|
||||
<div class="d-flex align-items-start">
|
||||
<input type="checkbox" class="form-check-input me-2 mt-1" v-model="t.selected" />
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold">{{ t.requestTitle }}</div>
|
||||
<small class="text-muted">
|
||||
[{{ getTypeLabel(t.taskType) }}] {{ t.requesterName || '-' }}
|
||||
<span v-if="t.resolutionContent"> → {{ truncate(t.resolutionContent, 30) }}</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Step 2: AI 변환 결과 -->
|
||||
<div v-if="maintenanceStep === 'convert'">
|
||||
<div class="mb-2 text-muted small">AI가 생성한 실적 문장 (수정 가능)</div>
|
||||
<div v-for="(g, idx) in generatedTasks" :key="idx" class="mb-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">{{ getTypeLabel(g.taskType) }}</span>
|
||||
<input type="text" class="form-control" v-model="g.description" />
|
||||
<button class="btn btn-outline-danger" type="button" @click="generatedTasks.splice(idx, 1)">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showMaintenanceModal = false">취소</button>
|
||||
<template v-if="maintenanceStep === 'select'">
|
||||
<button type="button" class="btn btn-primary" @click="convertMaintenance"
|
||||
:disabled="!selectedMaintenanceCount || isConverting">
|
||||
<span v-if="isConverting"><span class="spinner-border spinner-border-sm me-1"></span></span>
|
||||
AI 실적 변환 ({{ selectedMaintenanceCount }}건)
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button type="button" class="btn btn-outline-secondary" @click="maintenanceStep = 'select'">이전</button>
|
||||
<button type="button" class="btn btn-primary" @click="applyMaintenanceTasks" :disabled="generatedTasks.length === 0">
|
||||
<i class="bi bi-check-lg me-1"></i>실적 추가
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showMaintenanceModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -508,6 +588,17 @@ const aiParsedResult = ref<{
|
||||
remarkDescription: string | null
|
||||
} | null>(null)
|
||||
|
||||
// 유지보수 불러오기 모달
|
||||
const showMaintenanceModal = ref(false)
|
||||
const maintenanceStep = ref<'select' | 'convert'>('select')
|
||||
const maintenanceProjectId = ref('')
|
||||
const maintenanceTasks = ref<any[]>([])
|
||||
const isLoadingMaintenance = ref(false)
|
||||
const isConverting = ref(false)
|
||||
const generatedTasks = ref<{ description: string; taskType: string; sourceTaskIds: number[] }[]>([])
|
||||
|
||||
const selectedMaintenanceCount = computed(() => maintenanceTasks.value.filter(t => t.selected).length)
|
||||
|
||||
const form = ref({
|
||||
reportYear: new Date().getFullYear(),
|
||||
reportWeek: 1,
|
||||
@@ -922,6 +1013,94 @@ function closeAiModal() {
|
||||
aiParsedResult.value = null
|
||||
}
|
||||
|
||||
// 유지보수 불러오기 함수들
|
||||
function openMaintenanceModal() {
|
||||
showMaintenanceModal.value = true
|
||||
maintenanceStep.value = 'select'
|
||||
maintenanceProjectId.value = ''
|
||||
maintenanceTasks.value = []
|
||||
generatedTasks.value = []
|
||||
}
|
||||
|
||||
async function loadMaintenanceTasks() {
|
||||
if (!maintenanceProjectId.value) {
|
||||
maintenanceTasks.value = []
|
||||
return
|
||||
}
|
||||
isLoadingMaintenance.value = true
|
||||
try {
|
||||
const res = await $fetch<{ tasks: any[] }>('/api/maintenance/report/available', {
|
||||
query: {
|
||||
projectId: maintenanceProjectId.value,
|
||||
weekStartDate: form.value.weekStartDate,
|
||||
weekEndDate: form.value.weekEndDate
|
||||
}
|
||||
})
|
||||
maintenanceTasks.value = (res.tasks || []).map(t => ({ ...t, selected: true }))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
maintenanceTasks.value = []
|
||||
} finally {
|
||||
isLoadingMaintenance.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function convertMaintenance() {
|
||||
const selected = maintenanceTasks.value.filter(t => t.selected)
|
||||
if (selected.length === 0) return
|
||||
|
||||
isConverting.value = true
|
||||
try {
|
||||
const res = await $fetch<{ generatedTasks: any[] }>('/api/maintenance/report/generate-text', {
|
||||
method: 'POST',
|
||||
body: { tasks: selected }
|
||||
})
|
||||
generatedTasks.value = res.generatedTasks || []
|
||||
maintenanceStep.value = 'convert'
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
// 실패 시 기본 변환
|
||||
generatedTasks.value = selected.map(t => ({
|
||||
description: `[${getTypeLabel(t.taskType)}] ${t.requestTitle}`,
|
||||
taskType: t.taskType,
|
||||
sourceTaskIds: [t.taskId]
|
||||
}))
|
||||
maintenanceStep.value = 'convert'
|
||||
} finally {
|
||||
isConverting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyMaintenanceTasks() {
|
||||
const projectId = Number(maintenanceProjectId.value)
|
||||
const proj = allProjects.value.find(p => p.projectId === projectId)
|
||||
|
||||
for (const g of generatedTasks.value) {
|
||||
form.value.tasks.push({
|
||||
projectId,
|
||||
projectCode: proj?.projectCode || '',
|
||||
projectName: proj?.projectName || '',
|
||||
taskType: 'WORK',
|
||||
description: g.description,
|
||||
hours: 0,
|
||||
isCompleted: true
|
||||
})
|
||||
}
|
||||
|
||||
showMaintenanceModal.value = false
|
||||
toast.success(`${generatedTasks.value.length}건의 실적이 추가되었습니다.`)
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = { bug: '버그수정', feature: '기능개선', inquiry: '문의대응', other: '기타' }
|
||||
return labels[type] || '기타'
|
||||
}
|
||||
|
||||
function truncate(s: string, len: number): string {
|
||||
if (!s) return ''
|
||||
return s.length > len ? s.substring(0, len) + '...' : s
|
||||
}
|
||||
|
||||
function handleAiDrop(e: DragEvent) {
|
||||
isDragging.value = false
|
||||
const files = e.dataTransfer?.files
|
||||
|
||||
351
frontend/todo/index.vue
Normal file
351
frontend/todo/index.vue
Normal file
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0"><i class="bi bi-check2-square me-2"></i>TODO 관리</h4>
|
||||
<button class="btn btn-primary" @click="openCreateModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>새 TODO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-1 text-end"><label class="col-form-label">프로젝트</label></div>
|
||||
<div class="col-2">
|
||||
<select class="form-select form-select-sm" v-model="filter.projectId" @change="loadTodos">
|
||||
<option value="">전체</option>
|
||||
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">{{ p.projectName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-1 text-end"><label class="col-form-label">담당자</label></div>
|
||||
<div class="col-2">
|
||||
<select class="form-select form-select-sm" v-model="filter.assigneeId" @change="loadTodos">
|
||||
<option value="">전체</option>
|
||||
<option v-for="e in employees" :key="e.employeeId" :value="e.employeeId">{{ e.employeeName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-1 text-end"><label class="col-form-label">상태</label></div>
|
||||
<div class="col-3">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="status" id="statusAll" value="" v-model="filter.status" @change="loadTodos">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusAll">전체</label>
|
||||
<input type="radio" class="btn-check" name="status" id="statusPending" value="PENDING" v-model="filter.status" @change="loadTodos">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusPending">대기</label>
|
||||
<input type="radio" class="btn-check" name="status" id="statusProgress" value="IN_PROGRESS" v-model="filter.status" @change="loadTodos">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusProgress">진행</label>
|
||||
<input type="radio" class="btn-check" name="status" id="statusCompleted" value="COMPLETED" v-model="filter.status" @change="loadTodos">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="statusCompleted">완료</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="myOnly" v-model="filter.myOnly" @change="loadTodos">
|
||||
<label class="form-check-label" for="myOnly">내 TODO만</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 -->
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px" class="text-center">No</th>
|
||||
<th>제목</th>
|
||||
<th style="width: 120px">프로젝트</th>
|
||||
<th style="width: 80px">담당자</th>
|
||||
<th style="width: 100px">마감일</th>
|
||||
<th style="width: 80px" class="text-center">상태</th>
|
||||
<th style="width: 100px">등록일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="7" class="text-center py-4"><span class="spinner-border spinner-border-sm"></span></td>
|
||||
</tr>
|
||||
<tr v-else-if="todos.length === 0">
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox display-4"></i>
|
||||
<p class="mt-2 mb-0">TODO가 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="(todo, idx) in todos" :key="todo.todoId" @click="openEditModal(todo)" style="cursor: pointer">
|
||||
<td class="text-center">{{ idx + 1 }}</td>
|
||||
<td>
|
||||
<div class="fw-bold">{{ todo.todoTitle }}</div>
|
||||
<small class="text-muted" v-if="todo.meetingTitle">📋 {{ todo.meetingTitle }}</small>
|
||||
</td>
|
||||
<td>{{ todo.projectName || '-' }}</td>
|
||||
<td>{{ todo.assigneeName || '-' }}</td>
|
||||
<td :class="{ 'text-danger': isOverdue(todo) }">{{ formatDate(todo.dueDate) }}</td>
|
||||
<td class="text-center">
|
||||
<span :class="getStatusBadge(todo.status)">{{ getStatusLabel(todo.status) }}</span>
|
||||
</td>
|
||||
<td>{{ formatDate(todo.createdAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 생성/수정 모달 -->
|
||||
<div class="modal fade" :class="{ show: showModal }" :style="{ display: showModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ isEdit ? 'TODO 수정' : '새 TODO' }}</h5>
|
||||
<button type="button" class="btn-close" @click="showModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">제목 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="form.todoTitle" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">내용</label>
|
||||
<textarea class="form-control" v-model="form.todoContent" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">프로젝트</label>
|
||||
<select class="form-select" v-model="form.projectId">
|
||||
<option value="">선택 안함</option>
|
||||
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">{{ p.projectName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">담당자</label>
|
||||
<select class="form-select" v-model="form.assigneeId">
|
||||
<option value="">미지정</option>
|
||||
<option v-for="e in employees" :key="e.employeeId" :value="e.employeeId">{{ e.employeeName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">마감일</label>
|
||||
<input type="date" class="form-control" v-model="form.dueDate" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">상태</label>
|
||||
<select class="form-select" v-model="form.status">
|
||||
<option value="PENDING">대기</option>
|
||||
<option value="IN_PROGRESS">진행중</option>
|
||||
<option value="COMPLETED">완료</option>
|
||||
<option value="CANCELLED">취소</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button v-if="isEdit" type="button" class="btn btn-outline-danger me-auto" @click="deleteTodo">삭제</button>
|
||||
<button type="button" class="btn btn-secondary" @click="showModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="saveTodo" :disabled="isSaving">
|
||||
{{ isSaving ? '저장 중...' : '저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
interface Todo {
|
||||
todoId: number
|
||||
todoTitle: string
|
||||
todoContent: string
|
||||
projectId: number | null
|
||||
projectName: string | null
|
||||
meetingId: number | null
|
||||
meetingTitle: string | null
|
||||
assigneeId: number | null
|
||||
assigneeName: string | null
|
||||
dueDate: string | null
|
||||
status: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Project { projectId: number; projectName: string }
|
||||
interface Employee { employeeId: number; employeeName: string }
|
||||
|
||||
const todos = ref<Todo[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const employees = ref<Employee[]>([])
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const showModal = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editTodoId = ref<number | null>(null)
|
||||
|
||||
const filter = ref({
|
||||
projectId: '',
|
||||
assigneeId: '',
|
||||
status: '',
|
||||
myOnly: false
|
||||
})
|
||||
|
||||
const form = ref({
|
||||
todoTitle: '',
|
||||
todoContent: '',
|
||||
projectId: '',
|
||||
assigneeId: '',
|
||||
dueDate: '',
|
||||
status: 'PENDING'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) { router.push('/login'); return }
|
||||
await Promise.all([loadProjects(), loadEmployees(), loadTodos()])
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await $fetch<{ projects: Project[] }>('/api/project/list')
|
||||
projects.value = res.projects || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
try {
|
||||
const res = await $fetch<{ employees: Employee[] }>('/api/employee/list')
|
||||
employees.value = res.employees || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadTodos() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<{ todos: Todo[] }>('/api/todo/list', {
|
||||
query: {
|
||||
projectId: filter.value.projectId || undefined,
|
||||
assigneeId: filter.value.assigneeId || undefined,
|
||||
status: filter.value.status || undefined,
|
||||
myOnly: filter.value.myOnly || undefined
|
||||
}
|
||||
})
|
||||
todos.value = res.todos || []
|
||||
} catch (e) { console.error(e) }
|
||||
finally { isLoading.value = false }
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
isEdit.value = false
|
||||
editTodoId.value = null
|
||||
form.value = { todoTitle: '', todoContent: '', projectId: '', assigneeId: '', dueDate: '', status: 'PENDING' }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(todo: Todo) {
|
||||
isEdit.value = true
|
||||
editTodoId.value = todo.todoId
|
||||
form.value = {
|
||||
todoTitle: todo.todoTitle,
|
||||
todoContent: todo.todoContent || '',
|
||||
projectId: todo.projectId?.toString() || '',
|
||||
assigneeId: todo.assigneeId?.toString() || '',
|
||||
dueDate: todo.dueDate?.split('T')[0] || '',
|
||||
status: todo.status
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function saveTodo() {
|
||||
if (!form.value.todoTitle.trim()) {
|
||||
alert('제목을 입력해주세요.')
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (isEdit.value && editTodoId.value) {
|
||||
await $fetch(`/api/todo/${editTodoId.value}/update`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
todoTitle: form.value.todoTitle,
|
||||
todoContent: form.value.todoContent || null,
|
||||
projectId: form.value.projectId ? Number(form.value.projectId) : null,
|
||||
assigneeId: form.value.assigneeId ? Number(form.value.assigneeId) : null,
|
||||
dueDate: form.value.dueDate || null,
|
||||
status: form.value.status
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await $fetch('/api/todo/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
todoTitle: form.value.todoTitle,
|
||||
todoContent: form.value.todoContent || null,
|
||||
projectId: form.value.projectId ? Number(form.value.projectId) : null,
|
||||
assigneeId: form.value.assigneeId ? Number(form.value.assigneeId) : null,
|
||||
dueDate: form.value.dueDate || null
|
||||
}
|
||||
})
|
||||
}
|
||||
showModal.value = false
|
||||
await loadTodos()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTodo() {
|
||||
if (!editTodoId.value) return
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return
|
||||
try {
|
||||
await $fetch(`/api/todo/${editTodoId.value}/delete`, { method: 'DELETE' })
|
||||
showModal.value = false
|
||||
await loadTodos()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const badges: Record<string, string> = {
|
||||
PENDING: 'badge bg-secondary',
|
||||
IN_PROGRESS: 'badge bg-primary',
|
||||
COMPLETED: 'badge bg-success',
|
||||
CANCELLED: 'badge bg-dark'
|
||||
}
|
||||
return badges[status] || 'badge bg-secondary'
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
PENDING: '대기',
|
||||
IN_PROGRESS: '진행',
|
||||
COMPLETED: '완료',
|
||||
CANCELLED: '취소'
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
function isOverdue(todo: Todo) {
|
||||
if (!todo.dueDate || todo.status === 'COMPLETED') return false
|
||||
return new Date(todo.dueDate) < new Date()
|
||||
}
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
if (!d) return '-'
|
||||
return d.split('T')[0]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show { background: rgba(0, 0, 0, 0.5); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user