Files
weeklyreport/frontend/admin/bulk-import.vue
2026-01-05 02:00:13 +09:00

693 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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> 주간보고 내용 입력
<ul class="nav nav-tabs card-header-tabs float-end">
<li class="nav-item">
<a class="nav-link" :class="{ active: inputMode === 'text' }" href="#" @click.prevent="inputMode = 'text'">
<i class="bi bi-fonts me-1"></i>텍스트
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{ active: inputMode === 'image' }" href="#" @click.prevent="inputMode = 'image'">
<i class="bi bi-image me-1"></i>이미지
</a>
</li>
</ul>
</div>
<div class="card-body">
<!-- 텍스트 입력 모드 -->
<div v-if="inputMode === 'text'">
<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 개발 완료 (8시간), UI 수정 (4시간)
- 차주: 테스트 진행 예정 (16시간)
- 이슈: 서버 메모리 부족
김철수 (kim@turbosoft.co.kr)
- I-PIMS 유지보수: 버그수정 3건 (12시간)
- 휴가: 1/10(금) 연차"
></textarea>
</div>
<div class="d-flex justify-content-end">
<button
class="btn btn-primary"
@click="parseText"
: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 v-if="inputMode === 'image'">
<div class="mb-3">
<label class="form-label">카카오톡, 슬랙 메신저 캡처 이미지를 업로드하세요</label>
<div
class="upload-zone p-5 text-center border rounded"
:class="{ 'border-primary bg-light': isDragging }"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
@click="($refs.fileInput as HTMLInputElement).click()"
>
<input
ref="fileInput"
type="file"
multiple
accept="image/*"
class="d-none"
@change="handleFileSelect"
/>
<i class="bi bi-cloud-arrow-up display-4 text-muted"></i>
<p class="mt-2 mb-0 text-muted">
이미지를 드래그하거나 클릭해서 업로드<br>
<small>(최대 10, PNG/JPG)</small>
</p>
</div>
</div>
<div v-if="uploadedImages.length > 0" class="mb-3">
<label class="form-label">업로드된 이미지 ({{ uploadedImages.length }})</label>
<div class="d-flex flex-wrap gap-2">
<div v-for="(img, idx) in uploadedImages" :key="idx" class="position-relative">
<img :src="img" class="rounded border" style="width: 120px; height: 120px; object-fit: cover;" />
<button
type="button"
class="btn btn-sm btn-danger position-absolute top-0 end-0 rounded-circle"
style="transform: translate(30%, -30%); width: 24px; height: 24px; padding: 0;"
@click="removeImage(idx)"
>
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button
class="btn btn-primary"
@click="parseImages"
:disabled="isParsing || uploadedImages.length === 0"
>
<span v-if="isParsing" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-robot me-1"></i>
AI 분석 ({{ uploadedImages.length }})
</button>
</div>
</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 align-items-end">
<div class="col-auto">
<label class="form-label">보고 주차</label>
<div class="input-group">
<button class="btn btn-outline-secondary" type="button" @click="changeWeek(-1)">
<i class="bi bi-chevron-left"></i>
</button>
<span class="input-group-text bg-white" style="min-width: 180px;">
<strong>{{ parsedData.reportYear }} {{ parsedData.reportWeek }}주차</strong>
</span>
<button class="btn btn-outline-secondary" type="button" @click="changeWeek(1)">
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
<div class="col-auto">
<label class="form-label">기간</label>
<div class="input-group">
<input type="date" class="form-control" v-model="parsedData.weekStartDate" @change="updateWeekFromDate" />
<span class="input-group-text">~</span>
<input type="date" class="form-control" v-model="parsedData.weekEndDate" readonly />
</div>
</div>
<div class="col-auto">
<button class="btn btn-outline-primary btn-sm" @click="setLastWeek">지난주</button>
<button class="btn btn-outline-secondary btn-sm ms-1" @click="setThisWeek">이번주</button>
</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="getHeaderClass(report)">
<div>
<span v-if="report.isEmployeeMatched" class="badge bg-success me-2">기존직원</span>
<span v-else-if="report.isNewEmployee" class="badge bg-info 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">직원</label>
<div class="form-check mb-2">
<input type="radio" class="form-check-input" :id="'emp-existing-'+rIdx" :value="false" v-model="report.createNewEmployee" />
<label class="form-check-label" :for="'emp-existing-'+rIdx">기존 직원 선택</label>
</div>
<select v-if="!report.createNewEmployee" class="form-select" v-model="report.matchedEmployeeId"
:class="{'is-invalid': !report.matchedEmployeeId && !report.createNewEmployee}">
<option :value="null">-- 선택 --</option>
<option v-for="emp in employees" :key="emp.employeeId" :value="emp.employeeId">
{{ emp.employeeName }} ({{ emp.employeeEmail }})
</option>
</select>
<div class="form-check mt-2">
<input type="radio" class="form-check-input" :id="'emp-new-'+rIdx" :value="true" v-model="report.createNewEmployee" />
<label class="form-check-label" :for="'emp-new-'+rIdx">신규 직원 생성</label>
</div>
<div v-if="report.createNewEmployee" class="mt-2 p-3 bg-light rounded">
<div class="mb-2">
<label class="form-label small">이름 <span class="text-danger">*</span></label>
<input type="text" class="form-control form-control-sm" v-model="report.employeeName" />
</div>
<div>
<label class="form-label small">이메일 <span class="text-danger">*</span></label>
<input type="email" class="form-control form-control-sm" v-model="report.employeeEmail" />
</div>
</div>
</div>
</div>
<!-- 프로젝트별 Task -->
<div v-for="(proj, pIdx) in report.projects" :key="pIdx" class="border rounded p-3 mb-3">
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">
프로젝트
<span v-if="!proj.matchedProjectId" class="badge bg-info ms-1">신규생성</span>
</label>
<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>
<input v-if="!proj.matchedProjectId" type="text" class="form-control mt-2"
v-model="proj.projectName" placeholder="신규 프로젝트명" />
</div>
</div>
<div class="row">
<!-- 금주 실적 Task -->
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold text-primary">금주 실적</label>
<button type="button" class="btn btn-sm btn-outline-primary" @click="addParsedTask(report, proj, 'work')">
<i class="bi bi-plus"></i>
</button>
</div>
<div v-for="(task, tIdx) in proj.workTasks" :key="'work-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start">
<div class="form-check pt-1">
<input type="checkbox" class="form-check-input" v-model="task.isCompleted" />
</div>
<textarea class="form-control form-control-sm auto-resize" v-model="task.description"
@input="autoResize($event)" placeholder="작업 내용"></textarea>
<div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 60px;"
v-model.number="task.hours" min="0" step="0.5" />
<div class="small text-muted text-end">{{ formatHours(task.hours) }}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeParsedTask(proj.workTasks, tIdx)">
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
<!-- 차주 계획 Task -->
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold text-success">차주 계획</label>
<button type="button" class="btn btn-sm btn-outline-success" @click="addParsedTask(report, proj, 'plan')">
<i class="bi bi-plus"></i>
</button>
</div>
<div v-for="(task, tIdx) in proj.planTasks" :key="'plan-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start">
<textarea class="form-control form-control-sm auto-resize" v-model="task.description"
@input="autoResize($event)" placeholder="계획 내용"></textarea>
<div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 60px;"
v-model.number="task.hours" min="0" step="0.5" />
<div class="small text-muted text-end">{{ formatHours(task.hours) }}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeParsedTask(proj.planTasks, tIdx)">
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 공통사항 -->
<div class="row mt-3">
<div class="col-md-4">
<label class="form-label small">이슈/리스크</label>
<textarea class="form-control form-control-sm" v-model="report.issueDescription" rows="2"></textarea>
</div>
<div class="col-md-4">
<label class="form-label small">휴가일정</label>
<textarea class="form-control form-control-sm" v-model="report.vacationDescription" rows="2"></textarea>
</div>
<div class="col-md-4">
<label class="form-label small">기타사항</label>
<textarea class="form-control form-control-sm" 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 }}
<small class="text-muted ms-1">({{ r.employeeEmail }})</small>
</span>
<span v-if="r.success">
<span v-if="r.isNewEmployee" class="badge bg-info me-1">직원생성</span>
<span v-if="r.newProjects?.length" class="badge bg-secondary me-1">
프로젝트 {{ r.newProjects.length }} 생성
</span>
<span class="badge" :class="r.isUpdate ? 'bg-warning' : 'bg-success'">
{{ r.isUpdate ? '덮어쓰기' : '신규등록' }}
</span>
</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 inputMode = ref<'text' | 'image'>('text')
const rawText = ref('')
const uploadedImages = ref<string[]>([])
const isDragging = ref(false)
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) => {
if (r.createNewEmployee) {
return r.employeeName && r.employeeEmail
}
return r.matchedEmployeeId
})
})
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) {
router.push('/login')
return
}
if (user.employeeEmail !== 'coziny@gmail.com') {
alert('관리자만 접근할 수 있습니다.')
router.push('/')
return
}
})
function getHeaderClass(report: any) {
if (report.isEmployeeMatched) return 'bg-light'
if (report.isNewEmployee) return 'bg-info bg-opacity-25'
return 'bg-warning bg-opacity-25'
}
// 시간 표시 함수
function formatHours(hours: number): string {
if (!hours || hours <= 0) return '-'
const days = Math.floor(hours / 8)
const remainHours = hours % 8
if (days === 0) return `${remainHours}h`
if (remainHours === 0) return `${days}`
return `${days}${remainHours}h`
}
// textarea 자동 높이 조절
function autoResize(e: Event) {
const el = e.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
function resizeAllTextareas() {
nextTick(() => {
document.querySelectorAll('.auto-resize').forEach((el) => {
const textarea = el as HTMLTextAreaElement
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
})
})
}
// 이미지 관련 함수들
function handleDrop(e: DragEvent) {
isDragging.value = false
const files = e.dataTransfer?.files
if (files) processFiles(files)
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files) processFiles(input.files)
}
function processFiles(files: FileList) {
const maxFiles = 10 - uploadedImages.value.length
const toProcess = Array.from(files).slice(0, maxFiles)
toProcess.forEach(file => {
if (!file.type.startsWith('image/')) return
const reader = new FileReader()
reader.onload = (e) => {
if (e.target?.result) {
uploadedImages.value.push(e.target.result as string)
}
}
reader.readAsDataURL(file)
})
}
function removeImage(idx: number) {
uploadedImages.value.splice(idx, 1)
}
// Task 추가/삭제
function addParsedTask(report: any, proj: any, type: 'work' | 'plan') {
const taskArray = type === 'work' ? proj.workTasks : proj.planTasks
if (type === 'work') {
taskArray.push({ description: '', hours: 0, isCompleted: true })
} else {
taskArray.push({ description: '', hours: 0 })
}
}
function removeParsedTask(taskArray: any[], idx: number) {
if (taskArray.length > 0) {
taskArray.splice(idx, 1)
}
}
// 주차 계산 함수들
function getMonday(date: Date): Date {
const d = new Date(date)
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
d.setDate(diff)
return d
}
function getSunday(monday: Date): Date {
const d = new Date(monday)
d.setDate(d.getDate() + 6)
return d
}
function formatDate(date: Date): string {
return date.toISOString().split('T')[0]
}
function getWeekNumber(date: Date): { year: number; week: number } {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
d.setDate(d.getDate() + 3 - (d.getDay() + 6) % 7)
const week1 = new Date(d.getFullYear(), 0, 4)
const weekNum = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7)
return { year: d.getFullYear(), week: weekNum }
}
function setWeekDates(monday: Date) {
const sunday = getSunday(monday)
const weekInfo = getWeekNumber(monday)
parsedData.value.weekStartDate = formatDate(monday)
parsedData.value.weekEndDate = formatDate(sunday)
parsedData.value.reportYear = weekInfo.year
parsedData.value.reportWeek = weekInfo.week
}
function changeWeek(delta: number) {
const currentMonday = new Date(parsedData.value.weekStartDate)
currentMonday.setDate(currentMonday.getDate() + (delta * 7))
setWeekDates(currentMonday)
}
function setLastWeek() {
const today = new Date()
const lastWeekMonday = getMonday(today)
lastWeekMonday.setDate(lastWeekMonday.getDate() - 7)
setWeekDates(lastWeekMonday)
}
function setThisWeek() {
const today = new Date()
const thisWeekMonday = getMonday(today)
setWeekDates(thisWeekMonday)
}
function updateWeekFromDate() {
const startDate = new Date(parsedData.value.weekStartDate)
const monday = getMonday(startDate)
setWeekDates(monday)
}
// 분석 결과 처리
function handleParseResult(res: any) {
parsedData.value = res.parsed
parsedData.value.reports.forEach((r: any) => {
r.enabled = true
r.createNewEmployee = r.isNewEmployee
// workTasks, planTasks가 없으면 빈 배열로 초기화
r.projects.forEach((p: any) => {
p.workTasks = (p.workTasks || []).map((t: any) => ({
...t,
isCompleted: t.isCompleted !== false
}))
p.planTasks = p.planTasks || []
})
})
employees.value = res.employees
projects.value = res.projects
step.value = 2
resizeAllTextareas()
}
// 텍스트 분석
async function parseText() {
isParsing.value = true
try {
const res = await $fetch<any>('/api/admin/parse-report', {
method: 'POST',
body: { rawText: rawText.value }
})
handleParseResult(res)
} catch (e: any) {
alert(e.data?.message || e.message || 'AI 분석에 실패했습니다.')
} finally {
isParsing.value = false
}
}
// 이미지 분석
async function parseImages() {
isParsing.value = true
try {
const res = await $fetch<any>('/api/admin/parse-image', {
method: 'POST',
body: { images: uploadedImages.value }
})
handleParseResult(res)
} 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.createNewEmployee ? null : r.matchedEmployeeId,
employeeName: r.employeeName,
employeeEmail: r.employeeEmail,
projects: r.projects.map((p: any) => ({
projectId: p.matchedProjectId,
projectName: p.projectName,
workTasks: (p.workTasks || []).filter((t: any) => t.description?.trim()),
planTasks: (p.planTasks || []).filter((t: any) => t.description?.trim())
})),
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 = ''
uploadedImages.value = []
parsedData.value = {
reportYear: new Date().getFullYear(),
reportWeek: 1,
weekStartDate: '',
weekEndDate: '',
reports: []
}
registerResult.value = {}
}
</script>
<style scoped>
.upload-zone {
cursor: pointer;
transition: all 0.2s;
border-style: dashed !important;
border-width: 2px !important;
}
.upload-zone:hover {
border-color: #0d6efd !important;
background-color: #f8f9fa;
}
.nav-tabs .nav-link {
color: #6c757d;
}
.nav-tabs .nav-link.active {
color: #0d6efd;
font-weight: 500;
}
.auto-resize {
resize: none;
overflow: hidden;
min-height: 38px;
}
</style>