ㅋㅓ밋

This commit is contained in:
2026-01-04 21:31:45 +09:00
parent 0660ed3973
commit 93187f3809
13 changed files with 903 additions and 59 deletions

View File

@@ -0,0 +1,329 @@
<template>
<div>
<AppHeader />
<div class="container-fluid py-4">
<h4 class="mb-4">
<i class="bi bi-file-earmark-arrow-up me-2"></i>주간보고 일괄등록
</h4>
<!-- Step 1: 텍스트 입력 -->
<div class="card mb-4" v-if="step === 1">
<div class="card-header">
<strong>1단계:</strong> 주간보고 내용 붙여넣기
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">직원들의 주간보고 내용을 붙여넣으세요</label>
<textarea
class="form-control font-monospace"
v-model="rawText"
rows="15"
placeholder="예시:
홍길동 (hong@turbosoft.co.kr)
- PIMS 고도화: API 개발 완료
- 차주: 테스트 진행
- 이슈: 서버 메모리 부족
김철수 (kim@turbosoft.co.kr)
- I-PIMS 유지보수: 버그수정 3건
- 휴가: 1/10(금) 연차"
></textarea>
</div>
<div class="d-flex justify-content-end">
<button
class="btn btn-primary"
@click="parseReport"
:disabled="isParsing || !rawText.trim()"
>
<span v-if="isParsing" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-robot me-1"></i>
AI 분석
</button>
</div>
</div>
</div>
<!-- Step 2: 분석 결과 확인 -->
<div v-if="step === 2">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><strong>2단계:</strong> 분석 결과 확인 수정</span>
<button class="btn btn-sm btn-outline-secondary" @click="step = 1">
<i class="bi bi-arrow-left me-1"></i>다시 입력
</button>
</div>
<div class="card-body">
<!-- 주차 정보 -->
<div class="row mb-4">
<div class="col-md-2">
<label class="form-label">연도</label>
<input type="number" class="form-control" v-model="parsedData.reportYear" />
</div>
<div class="col-md-2">
<label class="form-label">주차</label>
<input type="number" class="form-control" v-model="parsedData.reportWeek" />
</div>
<div class="col-md-3">
<label class="form-label">시작일</label>
<input type="date" class="form-control" v-model="parsedData.weekStartDate" />
</div>
<div class="col-md-3">
<label class="form-label">종료일</label>
<input type="date" class="form-control" v-model="parsedData.weekEndDate" />
</div>
</div>
<hr />
<!-- 직원별 보고서 -->
<div v-for="(report, rIdx) in parsedData.reports" :key="rIdx" class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center"
:class="report.isEmployeeMatched ? 'bg-light' : 'bg-warning bg-opacity-25'">
<div>
<span v-if="report.isEmployeeMatched" class="badge bg-success me-2">매칭됨</span>
<span v-else class="badge bg-warning text-dark me-2">매칭필요</span>
<strong>{{ report.employeeName }}</strong>
<small class="text-muted ms-2">{{ report.employeeEmail || '이메일 없음' }}</small>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" v-model="report.enabled" :id="'chk-'+rIdx" />
<label class="form-check-label" :for="'chk-'+rIdx">등록</label>
</div>
</div>
<div class="card-body" v-if="report.enabled">
<!-- 직원 선택 -->
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">직원 선택 <span class="text-danger">*</span></label>
<select class="form-select" v-model="report.matchedEmployeeId"
:class="{'is-invalid': !report.matchedEmployeeId}">
<option :value="null">-- 선택 --</option>
<option v-for="emp in employees" :key="emp.employeeId" :value="emp.employeeId">
{{ emp.employeeName }} ({{ emp.employeeEmail }})
</option>
</select>
</div>
</div>
<!-- 프로젝트별 실적 -->
<div v-for="(proj, pIdx) in report.projects" :key="pIdx" class="border rounded p-3 mb-2">
<div class="row mb-2">
<div class="col-md-6">
<label class="form-label">프로젝트</label>
<div class="input-group">
<select class="form-select" v-model="proj.matchedProjectId">
<option :value="null"> 신규 생성</option>
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">
{{ p.projectCode }} - {{ p.projectName }}
</option>
</select>
</div>
<input
v-if="!proj.matchedProjectId"
type="text"
class="form-control mt-2"
v-model="proj.projectName"
placeholder="신규 프로젝트명"
/>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label class="form-label">금주 실적</label>
<textarea class="form-control" v-model="proj.workDescription" rows="2"></textarea>
</div>
<div class="col-md-6">
<label class="form-label">차주 계획</label>
<textarea class="form-control" v-model="proj.planDescription" rows="2"></textarea>
</div>
</div>
</div>
<!-- 공통사항 -->
<div class="row mt-3">
<div class="col-md-4">
<label class="form-label">이슈/리스크</label>
<textarea class="form-control" v-model="report.issueDescription" rows="2"></textarea>
</div>
<div class="col-md-4">
<label class="form-label">휴가일정</label>
<textarea class="form-control" v-model="report.vacationDescription" rows="2"></textarea>
</div>
<div class="col-md-4">
<label class="form-label">기타사항</label>
<textarea class="form-control" v-model="report.remarkDescription" rows="2"></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-outline-secondary" @click="step = 1">취소</button>
<button
class="btn btn-primary"
@click="bulkRegister"
:disabled="isRegistering || !canRegister"
>
<span v-if="isRegistering" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-check-lg me-1"></i>
일괄 등록 ({{ enabledCount }})
</button>
</div>
</div>
<!-- Step 3: 결과 -->
<div class="card" v-if="step === 3">
<div class="card-header bg-success text-white">
<i class="bi bi-check-circle me-2"></i>등록 완료
</div>
<div class="card-body">
<p class="mb-3">
<strong>{{ registerResult.totalCount }}</strong>
<strong class="text-success">{{ registerResult.successCount }}</strong> 등록 완료
</p>
<ul class="list-group">
<li v-for="(r, idx) in registerResult.results" :key="idx"
class="list-group-item d-flex justify-content-between align-items-center">
<span>
<i :class="r.success ? 'bi bi-check-circle text-success' : 'bi bi-x-circle text-danger'" class="me-2"></i>
{{ r.employeeName }}
</span>
<span v-if="r.success" class="badge" :class="r.isUpdate ? 'bg-warning' : 'bg-success'">
{{ r.isUpdate ? '덮어쓰기' : '신규등록' }}
</span>
<span v-else class="text-danger small">{{ r.error }}</span>
</li>
</ul>
<div class="mt-4">
<button class="btn btn-primary" @click="reset">
<i class="bi bi-plus-lg me-1"></i>추가 등록
</button>
<NuxtLink to="/report/weekly" class="btn btn-outline-secondary ms-2">
주간보고 목록
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const { fetchCurrentUser } = useAuth()
const router = useRouter()
const step = ref(1)
const rawText = ref('')
const isParsing = ref(false)
const isRegistering = ref(false)
const parsedData = ref<any>({
reportYear: new Date().getFullYear(),
reportWeek: 1,
weekStartDate: '',
weekEndDate: '',
reports: []
})
const employees = ref<any[]>([])
const projects = ref<any[]>([])
const registerResult = ref<any>({})
const enabledCount = computed(() =>
parsedData.value.reports?.filter((r: any) => r.enabled).length || 0
)
const canRegister = computed(() => {
const enabledReports = parsedData.value.reports?.filter((r: any) => r.enabled) || []
return enabledReports.length > 0 && enabledReports.every((r: any) => r.matchedEmployeeId)
})
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) {
router.push('/login')
return
}
// 관리자 체크
if (user.employeeEmail !== 'coziny@gmail.com') {
alert('관리자만 접근할 수 있습니다.')
router.push('/')
return
}
})
async function parseReport() {
isParsing.value = true
try {
const res = await $fetch<any>('/api/admin/parse-report', {
method: 'POST',
body: { rawText: rawText.value }
})
parsedData.value = res.parsed
parsedData.value.reports.forEach((r: any) => r.enabled = true)
employees.value = res.employees
projects.value = res.projects
step.value = 2
} catch (e: any) {
alert(e.data?.message || e.message || 'AI 분석에 실패했습니다.')
} finally {
isParsing.value = false
}
}
async function bulkRegister() {
isRegistering.value = true
try {
const enabledReports = parsedData.value.reports
.filter((r: any) => r.enabled)
.map((r: any) => ({
employeeId: r.matchedEmployeeId,
projects: r.projects.map((p: any) => ({
projectId: p.matchedProjectId,
projectName: p.projectName,
workDescription: p.workDescription,
planDescription: p.planDescription
})),
issueDescription: r.issueDescription,
vacationDescription: r.vacationDescription,
remarkDescription: r.remarkDescription
}))
const res = await $fetch<any>('/api/admin/bulk-register', {
method: 'POST',
body: {
reportYear: parsedData.value.reportYear,
reportWeek: parsedData.value.reportWeek,
weekStartDate: parsedData.value.weekStartDate,
weekEndDate: parsedData.value.weekEndDate,
reports: enabledReports
}
})
registerResult.value = res
step.value = 3
} catch (e: any) {
alert(e.data?.message || e.message || '등록에 실패했습니다.')
} finally {
isRegistering.value = false
}
}
function reset() {
step.value = 1
rawText.value = ''
parsedData.value = {
reportYear: new Date().getFullYear(),
reportWeek: 1,
weekStartDate: '',
weekEndDate: '',
reports: []
}
registerResult.value = {}
}
</script>

View File

@@ -37,6 +37,19 @@
<i class="bi bi-people me-1"></i> 직원관리
</NuxtLink>
</li>
<!-- 관리자 메뉴 (coziny@gmail.com 전용) -->
<li class="nav-item dropdown" v-if="isAdmin">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-gear me-1"></i> 관리자
</a>
<ul class="dropdown-menu">
<li>
<NuxtLink class="dropdown-item" to="/admin/bulk-import">
<i class="bi bi-file-earmark-arrow-up me-2"></i>주간보고 일괄등록
</NuxtLink>
</li>
</ul>
</li>
</ul>
<!-- 사용자 정보 -->
@@ -58,6 +71,8 @@
const { currentUser, logout } = useAuth()
const router = useRouter()
const isAdmin = computed(() => currentUser.value?.employeeEmail === 'coziny@gmail.com')
async function handleLogout() {
await logout()
router.push('/login')

View File

@@ -11,7 +11,7 @@
<div class="row" v-if="employee">
<div class="col-lg-6">
<div class="card">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-person me-2"></i>직원 정보
@@ -87,22 +87,58 @@
</div>
<div class="col-lg-6">
<div class="card">
<!-- 기본 활동 정보 -->
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-clock-history me-2"></i>활동 이력
<i class="bi bi-info-circle me-2"></i>기본 정보
</div>
<div class="card-body">
<div class="mb-4">
<h6 class="text-muted">최근 로그인</h6>
<p class="mb-0">{{ employee.lastLoginAt ? formatDateTime(employee.lastLoginAt) : '-' }}</p>
<div class="row">
<div class="col-6 mb-3">
<small class="text-muted d-block">등록일</small>
<span>{{ formatDateTime(employee.createdAt) }}</span>
</div>
<div class="col-6 mb-3">
<small class="text-muted d-block">최종 수정</small>
<span>{{ formatDateTime(employee.updatedAt) }}</span>
</div>
</div>
<div class="mb-4">
<h6 class="text-muted">등록일</h6>
<p class="mb-0">{{ formatDateTime(employee.createdAt) }}</p>
</div>
<div>
<h6 class="text-muted">최종 수정</h6>
<p class="mb-0">{{ formatDateTime(employee.updatedAt) }}</p>
</div>
</div>
<!-- 로그인 이력 -->
<div class="card">
<div class="card-header">
<i class="bi bi-clock-history me-2"></i>로그인 이력
<small class="text-muted ms-2">(최근 20)</small>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>로그인 시간</th>
<th>IP</th>
<th>로그아웃</th>
<th style="width: 80px">상태</th>
</tr>
</thead>
<tbody>
<tr v-if="loginHistory.length === 0">
<td colspan="4" class="text-center text-muted py-3">로그인 이력이 없습니다.</td>
</tr>
<tr v-for="h in loginHistory" :key="h.historyId">
<td>{{ formatDateTime(h.loginAt) }}</td>
<td><code class="small">{{ h.loginIp || '-' }}</code></td>
<td>{{ h.logoutAt ? formatDateTime(h.logoutAt) : '-' }}</td>
<td>
<span v-if="h.logoutAt" class="badge bg-secondary">로그아웃</span>
<span v-else-if="h.isCurrentSession" class="badge bg-success">접속중</span>
<span v-else class="badge bg-warning text-dark">세션만료</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -122,6 +158,7 @@ const router = useRouter()
const route = useRoute()
const employee = ref<any>(null)
const loginHistory = ref<any[]>([])
const isLoading = ref(true)
const isSubmitting = ref(false)
@@ -148,8 +185,9 @@ onMounted(async () => {
async function loadEmployee() {
isLoading.value = true
try {
const res = await $fetch<{ employee: any }>(`/api/employee/${route.params.id}/detail`)
const res = await $fetch<{ employee: any, loginHistory: any[] }>(`/api/employee/${route.params.id}/detail`)
employee.value = res.employee
loginHistory.value = res.loginHistory || []
const e = res.employee
form.value = {

View File

@@ -60,7 +60,6 @@
<td><strong>{{ emp.employeeName }}</strong></td>
<td>{{ emp.employeeEmail }}</td>
<td>{{ emp.company || '-' }}</td>
<td>{{ emp.employeeEmail }}</td>
<td>{{ emp.employeePosition || '-' }}</td>
<td>
<span :class="emp.isActive !== false ? 'badge bg-success' : 'badge bg-secondary'">

View File

@@ -153,7 +153,8 @@
<td><code>{{ h.logoutIp || '-' }}</code></td>
<td>
<span v-if="h.logoutAt" class="badge bg-secondary">로그아웃</span>
<span v-else class="badge bg-success">로그인 </span>
<span v-else-if="h.isCurrentSession" class="badge bg-success">접속</span>
<span v-else class="badge bg-warning text-dark">세션만료</span>
</td>
</tr>
</tbody>