693 lines
26 KiB
Vue
693 lines
26 KiB
Vue
<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>
|